claudeCodeHints.ts
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 18 import { logForDebugging } from './debug.js' 19 import { createSignal } from './signal.js' 20 21 export type ClaudeCodeHintType = 'plugin' 22 23 export 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. */ 42 const SUPPORTED_VERSIONS = new Set([1]) 43 44 /** Hint types this harness understands at the supported versions. */ 45 const 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 */ 53 const 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 */ 61 const 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 */ 72 export 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 122 function 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 130 function 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 149 let pendingHint: ClaudeCodeHint | null = null 150 let shownThisSession = false 151 const pendingHintChanged = createSignal() 152 const notify = pendingHintChanged.emit 153 154 /** Raw store write. Callers should gate first (see module comment). */ 155 export 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. */ 162 export 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. */ 170 export function markShownThisSession(): void { 171 shownThisSession = true 172 } 173 174 export const subscribeToPendingHint = pendingHintChanged.subscribe 175 176 export function getPendingHintSnapshot(): ClaudeCodeHint | null { 177 return pendingHint 178 } 179 180 export function hasShownHintThisSession(): boolean { 181 return shownThisSession 182 } 183 184 /** Test-only reset. */ 185 export function _resetClaudeCodeHintStore(): void { 186 pendingHint = null 187 shownThisSession = false 188 } 189 190 export const _test = { 191 parseAttrs, 192 firstCommandToken, 193 }