claudeCodeHints.ts 6.3 KB

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