source dump of claude code
at main 193 lines 6.5 kB view raw
1/** 2 * Claude Code hints protocol. 3 * 4 * CLIs and SDKs running under Claude Code can emit a self-closing 5 * `<claude-code-hint />` tag to stderr (merged into stdout by the shell 6 * tools). The harness scans tool output for these tags, strips them before 7 * the output reaches the model, and surfaces an install prompt to the 8 * user — no inference, no proactive execution. 9 * 10 * This file provides both the parser and a small module-level store for 11 * the pending hint. The store is a single slot (not a queue) — we surface 12 * at most one prompt per session, so there's no reason to accumulate. 13 * React subscribes via useSyncExternalStore. 14 * 15 * See docs/claude-code-hints.md for the vendor-facing spec. 16 */ 17 18import { logForDebugging } from './debug.js' 19import { createSignal } from './signal.js' 20 21export type ClaudeCodeHintType = 'plugin' 22 23export type ClaudeCodeHint = { 24 /** Spec version declared by the emitter. Unknown versions are dropped. */ 25 v: number 26 /** Hint discriminator. v1 defines only `plugin`. */ 27 type: ClaudeCodeHintType 28 /** 29 * Hint payload. For `type: 'plugin'`: a `name@marketplace` slug 30 * matching the form accepted by `parsePluginIdentifier`. 31 */ 32 value: string 33 /** 34 * First token of the shell command that produced this hint. Shown in the 35 * install prompt so the user can spot a mismatch between the tool that 36 * emitted the hint and the plugin it recommends. 37 */ 38 sourceCommand: string 39} 40 41/** Spec versions this harness understands. */ 42const SUPPORTED_VERSIONS = new Set([1]) 43 44/** Hint types this harness understands at the supported versions. */ 45const SUPPORTED_TYPES = new Set<string>(['plugin']) 46 47/** 48 * Outer tag match. Anchored to whole lines (multiline mode) so that a 49 * hint marker buried in a larger line — e.g. a log statement quoting the 50 * tag — is ignored. Leading and trailing whitespace on the line is 51 * tolerated since some SDKs pad stderr. 52 */ 53const HINT_TAG_RE = /^[ \t]*<claude-code-hint\s+([^>]*?)\s*\/>[ \t]*$/gm 54 55/** 56 * Attribute matcher. Accepts `key="value"` and `key=value` (terminated by 57 * whitespace or `/>` closing sequence). Values containing whitespace or `"` must use the quoted 58 * form. The quoted form does not support escape sequences; raise the spec 59 * version if that becomes necessary. 60 */ 61const ATTR_RE = /(\w+)=(?:"([^"]*)"|([^\s/>]+))/g 62 63/** 64 * Scan shell tool output for hint tags, returning the parsed hints and 65 * the output with hint lines removed. The stripped output is what the 66 * model sees — hints are a harness-only side channel. 67 * 68 * @param output - Raw command output (stdout with stderr interleaved). 69 * @param command - The command that produced the output; its first 70 * whitespace-separated token is recorded as `sourceCommand`. 71 */ 72export function extractClaudeCodeHints( 73 output: string, 74 command: string, 75): { hints: ClaudeCodeHint[]; stripped: string } { 76 // Fast path: no tag open sequence → no work, no allocation. 77 if (!output.includes('<claude-code-hint')) { 78 return { hints: [], stripped: output } 79 } 80 81 const sourceCommand = firstCommandToken(command) 82 const hints: ClaudeCodeHint[] = [] 83 84 const stripped = output.replace(HINT_TAG_RE, rawLine => { 85 const attrs = parseAttrs(rawLine) 86 const v = Number(attrs.v) 87 const type = attrs.type 88 const value = attrs.value 89 90 if (!SUPPORTED_VERSIONS.has(v)) { 91 logForDebugging( 92 `[claudeCodeHints] dropped hint with unsupported v=${attrs.v}`, 93 ) 94 return '' 95 } 96 if (!type || !SUPPORTED_TYPES.has(type)) { 97 logForDebugging( 98 `[claudeCodeHints] dropped hint with unsupported type=${type}`, 99 ) 100 return '' 101 } 102 if (!value) { 103 logForDebugging('[claudeCodeHints] dropped hint with empty value') 104 return '' 105 } 106 107 hints.push({ v, type: type as ClaudeCodeHintType, value, sourceCommand }) 108 return '' 109 }) 110 111 // Dropping a matched line leaves a blank line (the surrounding newlines 112 // remain). Collapse runs of blank lines introduced by the replace so the 113 // model-visible output doesn't grow vertical whitespace. 114 const collapsed = 115 hints.length > 0 || stripped !== output 116 ? stripped.replace(/\n{3,}/g, '\n\n') 117 : stripped 118 119 return { hints, stripped: collapsed } 120} 121 122function parseAttrs(tagBody: string): Record<string, string> { 123 const attrs: Record<string, string> = {} 124 for (const m of tagBody.matchAll(ATTR_RE)) { 125 attrs[m[1]!] = m[2] ?? m[3] ?? '' 126 } 127 return attrs 128} 129 130function firstCommandToken(command: string): string { 131 const trimmed = command.trim() 132 const spaceIdx = trimmed.search(/\s/) 133 return spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx) 134} 135 136// ============================================================================ 137// Pending-hint store (useSyncExternalStore interface) 138// 139// Single-slot: write wins if the slot is already full (a CLI that emits on 140// every invocation would otherwise pile up). The dialog is shown at most 141// once per session; after that, setPendingHint becomes a no-op. 142// 143// Callers should gate before writing (installed? already shown? cap hit?) — 144// see maybeRecordPluginHint in hintRecommendation.ts for the plugin-type 145// gate. This module stays plugin-agnostic so future hint types can reuse 146// the same store. 147// ============================================================================ 148 149let pendingHint: ClaudeCodeHint | null = null 150let shownThisSession = false 151const pendingHintChanged = createSignal() 152const notify = pendingHintChanged.emit 153 154/** Raw store write. Callers should gate first (see module comment). */ 155export function setPendingHint(hint: ClaudeCodeHint): void { 156 if (shownThisSession) return 157 pendingHint = hint 158 notify() 159} 160 161/** Clear the slot without flipping the session flag — for rejected hints. */ 162export function clearPendingHint(): void { 163 if (pendingHint !== null) { 164 pendingHint = null 165 notify() 166 } 167} 168 169/** Flip the once-per-session flag. Call only when a dialog is actually shown. */ 170export function markShownThisSession(): void { 171 shownThisSession = true 172} 173 174export const subscribeToPendingHint = pendingHintChanged.subscribe 175 176export function getPendingHintSnapshot(): ClaudeCodeHint | null { 177 return pendingHint 178} 179 180export function hasShownHintThisSession(): boolean { 181 return shownThisSession 182} 183 184/** Test-only reset. */ 185export function _resetClaudeCodeHintStore(): void { 186 pendingHint = null 187 shownThisSession = false 188} 189 190export const _test = { 191 parseAttrs, 192 firstCommandToken, 193}