/ src / utils / claudeCodeHints.ts
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  }