claudeCodeHints.ts
utils/claudeCodeHints.ts
No strong subsystem tag
194
Lines
6472
Bytes
11
Exports
2
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 194 lines, 2 detected imports, and 11 detected exports.
Important relationships
Detected exports
ClaudeCodeHintTypeClaudeCodeHintextractClaudeCodeHintssetPendingHintclearPendingHintmarkShownThisSessionsubscribeToPendingHintgetPendingHintSnapshothasShownHintThisSession_resetClaudeCodeHintStore_test
Keywords
hintoutputhintscommandstrippedattrsclaudecodehintpendinghintstoreplugin
Detected imports
./debug.js./signal.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
/**
* Claude Code hints protocol.
*
* CLIs and SDKs running under Claude Code can emit a self-closing
* `<claude-code-hint />` tag to stderr (merged into stdout by the shell
* tools). The harness scans tool output for these tags, strips them before
* the output reaches the model, and surfaces an install prompt to the
* user — no inference, no proactive execution.
*
* This file provides both the parser and a small module-level store for
* the pending hint. The store is a single slot (not a queue) — we surface
* at most one prompt per session, so there's no reason to accumulate.
* React subscribes via useSyncExternalStore.
*
* See docs/claude-code-hints.md for the vendor-facing spec.
*/
import { logForDebugging } from './debug.js'
import { createSignal } from './signal.js'
export type ClaudeCodeHintType = 'plugin'
export type ClaudeCodeHint = {
/** Spec version declared by the emitter. Unknown versions are dropped. */
v: number
/** Hint discriminator. v1 defines only `plugin`. */
type: ClaudeCodeHintType
/**
* Hint payload. For `type: 'plugin'`: a `name@marketplace` slug
* matching the form accepted by `parsePluginIdentifier`.
*/
value: string
/**
* First token of the shell command that produced this hint. Shown in the
* install prompt so the user can spot a mismatch between the tool that
* emitted the hint and the plugin it recommends.
*/
sourceCommand: string
}
/** Spec versions this harness understands. */
const SUPPORTED_VERSIONS = new Set([1])
/** Hint types this harness understands at the supported versions. */
const SUPPORTED_TYPES = new Set<string>(['plugin'])
/**
* Outer tag match. Anchored to whole lines (multiline mode) so that a
* hint marker buried in a larger line — e.g. a log statement quoting the
* tag — is ignored. Leading and trailing whitespace on the line is
* tolerated since some SDKs pad stderr.
*/
const HINT_TAG_RE = /^[ \t]*<claude-code-hint\s+([^>]*?)\s*\/>[ \t]*$/gm
/**
* Attribute matcher. Accepts `key="value"` and `key=value` (terminated by
* whitespace or `/>` closing sequence). Values containing whitespace or `"` must use the quoted
* form. The quoted form does not support escape sequences; raise the spec
* version if that becomes necessary.
*/
const ATTR_RE = /(\w+)=(?:"([^"]*)"|([^\s/>]+))/g
/**
* Scan shell tool output for hint tags, returning the parsed hints and
* the output with hint lines removed. The stripped output is what the
* model sees — hints are a harness-only side channel.
*
* @param output - Raw command output (stdout with stderr interleaved).
* @param command - The command that produced the output; its first
* whitespace-separated token is recorded as `sourceCommand`.
*/
export function extractClaudeCodeHints(
output: string,
command: string,
): { hints: ClaudeCodeHint[]; stripped: string } {
// Fast path: no tag open sequence → no work, no allocation.
if (!output.includes('<claude-code-hint')) {
return { hints: [], stripped: output }
}
const sourceCommand = firstCommandToken(command)
const hints: ClaudeCodeHint[] = []
const stripped = output.replace(HINT_TAG_RE, rawLine => {
const attrs = parseAttrs(rawLine)
const v = Number(attrs.v)
const type = attrs.type
const value = attrs.value
if (!SUPPORTED_VERSIONS.has(v)) {
logForDebugging(
`[claudeCodeHints] dropped hint with unsupported v=${attrs.v}`,
)
return ''
}
if (!type || !SUPPORTED_TYPES.has(type)) {
logForDebugging(
`[claudeCodeHints] dropped hint with unsupported type=${type}`,
)
return ''
}
if (!value) {
logForDebugging('[claudeCodeHints] dropped hint with empty value')
return ''
}
hints.push({ v, type: type as ClaudeCodeHintType, value, sourceCommand })
return ''
})
// Dropping a matched line leaves a blank line (the surrounding newlines
// remain). Collapse runs of blank lines introduced by the replace so the
// model-visible output doesn't grow vertical whitespace.
const collapsed =
hints.length > 0 || stripped !== output
? stripped.replace(/\n{3,}/g, '\n\n')
: stripped
return { hints, stripped: collapsed }
}
function parseAttrs(tagBody: string): Record<string, string> {
const attrs: Record<string, string> = {}
for (const m of tagBody.matchAll(ATTR_RE)) {
attrs[m[1]!] = m[2] ?? m[3] ?? ''
}
return attrs
}
function firstCommandToken(command: string): string {
const trimmed = command.trim()
const spaceIdx = trimmed.search(/\s/)
return spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)
}
// ============================================================================
// Pending-hint store (useSyncExternalStore interface)
//
// Single-slot: write wins if the slot is already full (a CLI that emits on
// every invocation would otherwise pile up). The dialog is shown at most
// once per session; after that, setPendingHint becomes a no-op.
//
// Callers should gate before writing (installed? already shown? cap hit?) —
// see maybeRecordPluginHint in hintRecommendation.ts for the plugin-type
// gate. This module stays plugin-agnostic so future hint types can reuse
// the same store.
// ============================================================================
let pendingHint: ClaudeCodeHint | null = null
let shownThisSession = false
const pendingHintChanged = createSignal()
const notify = pendingHintChanged.emit
/** Raw store write. Callers should gate first (see module comment). */
export function setPendingHint(hint: ClaudeCodeHint): void {
if (shownThisSession) return
pendingHint = hint
notify()
}
/** Clear the slot without flipping the session flag — for rejected hints. */
export function clearPendingHint(): void {
if (pendingHint !== null) {
pendingHint = null
notify()
}
}
/** Flip the once-per-session flag. Call only when a dialog is actually shown. */
export function markShownThisSession(): void {
shownThisSession = true
}
export const subscribeToPendingHint = pendingHintChanged.subscribe
export function getPendingHintSnapshot(): ClaudeCodeHint | null {
return pendingHint
}
export function hasShownHintThisSession(): boolean {
return shownThisSession
}
/** Test-only reset. */
export function _resetClaudeCodeHintStore(): void {
pendingHint = null
shownThisSession = false
}
export const _test = {
parseAttrs,
firstCommandToken,
}