source dump of claude code
at main 464 lines 14 kB view raw
1import { appendFile, writeFile } from 'fs/promises' 2import { join } from 'path' 3import { getProjectRoot, getSessionId } from './bootstrap/state.js' 4import { registerCleanup } from './utils/cleanupRegistry.js' 5import type { HistoryEntry, PastedContent } from './utils/config.js' 6import { logForDebugging } from './utils/debug.js' 7import { getClaudeConfigHomeDir, isEnvTruthy } from './utils/envUtils.js' 8import { getErrnoCode } from './utils/errors.js' 9import { readLinesReverse } from './utils/fsOperations.js' 10import { lock } from './utils/lockfile.js' 11import { 12 hashPastedText, 13 retrievePastedText, 14 storePastedText, 15} from './utils/pasteStore.js' 16import { sleep } from './utils/sleep.js' 17import { jsonParse, jsonStringify } from './utils/slowOperations.js' 18 19const MAX_HISTORY_ITEMS = 100 20const MAX_PASTED_CONTENT_LENGTH = 1024 21 22/** 23 * Stored paste content - either inline content or a hash reference to paste store. 24 */ 25type StoredPastedContent = { 26 id: number 27 type: 'text' | 'image' 28 content?: string // Inline content for small pastes 29 contentHash?: string // Hash reference for large pastes stored externally 30 mediaType?: string 31 filename?: string 32} 33 34/** 35 * Claude Code parses history for pasted content references to match back to 36 * pasted content. The references look like: 37 * Text: [Pasted text #1 +10 lines] 38 * Image: [Image #2] 39 * The numbers are expected to be unique within a single prompt but not across 40 * prompts. We choose numeric, auto-incrementing IDs as they are more 41 * user-friendly than other ID options. 42 */ 43 44// Note: The original text paste implementation would consider input like 45// "line1\nline2\nline3" to have +2 lines, not 3 lines. We preserve that 46// behavior here. 47export function getPastedTextRefNumLines(text: string): number { 48 return (text.match(/\r\n|\r|\n/g) || []).length 49} 50 51export function formatPastedTextRef(id: number, numLines: number): string { 52 if (numLines === 0) { 53 return `[Pasted text #${id}]` 54 } 55 return `[Pasted text #${id} +${numLines} lines]` 56} 57 58export function formatImageRef(id: number): string { 59 return `[Image #${id}]` 60} 61 62export function parseReferences( 63 input: string, 64): Array<{ id: number; match: string; index: number }> { 65 const referencePattern = 66 /\[(Pasted text|Image|\.\.\.Truncated text) #(\d+)(?: \+\d+ lines)?(\.)*\]/g 67 const matches = [...input.matchAll(referencePattern)] 68 return matches 69 .map(match => ({ 70 id: parseInt(match[2] || '0'), 71 match: match[0], 72 index: match.index, 73 })) 74 .filter(match => match.id > 0) 75} 76 77/** 78 * Replace [Pasted text #N] placeholders in input with their actual content. 79 * Image refs are left alone — they become content blocks, not inlined text. 80 */ 81export function expandPastedTextRefs( 82 input: string, 83 pastedContents: Record<number, PastedContent>, 84): string { 85 const refs = parseReferences(input) 86 let expanded = input 87 // Splice at the original match offsets so placeholder-like strings inside 88 // pasted content are never confused for real refs. Reverse order keeps 89 // earlier offsets valid after later replacements. 90 for (let i = refs.length - 1; i >= 0; i--) { 91 const ref = refs[i]! 92 const content = pastedContents[ref.id] 93 if (content?.type !== 'text') continue 94 expanded = 95 expanded.slice(0, ref.index) + 96 content.content + 97 expanded.slice(ref.index + ref.match.length) 98 } 99 return expanded 100} 101 102function deserializeLogEntry(line: string): LogEntry { 103 return jsonParse(line) as LogEntry 104} 105 106async function* makeLogEntryReader(): AsyncGenerator<LogEntry> { 107 const currentSession = getSessionId() 108 109 // Start with entries that have yet to be flushed to disk 110 for (let i = pendingEntries.length - 1; i >= 0; i--) { 111 yield pendingEntries[i]! 112 } 113 114 // Read from global history file (shared across all projects) 115 const historyPath = join(getClaudeConfigHomeDir(), 'history.jsonl') 116 117 try { 118 for await (const line of readLinesReverse(historyPath)) { 119 try { 120 const entry = deserializeLogEntry(line) 121 // removeLastFromHistory slow path: entry was flushed before removal, 122 // so filter here so both getHistory (Up-arrow) and makeHistoryReader 123 // (ctrl+r search) skip it consistently. 124 if ( 125 entry.sessionId === currentSession && 126 skippedTimestamps.has(entry.timestamp) 127 ) { 128 continue 129 } 130 yield entry 131 } catch (error) { 132 // Not a critical error - just skip malformed lines 133 logForDebugging(`Failed to parse history line: ${error}`) 134 } 135 } 136 } catch (e: unknown) { 137 const code = getErrnoCode(e) 138 if (code === 'ENOENT') { 139 return 140 } 141 throw e 142 } 143} 144 145export async function* makeHistoryReader(): AsyncGenerator<HistoryEntry> { 146 for await (const entry of makeLogEntryReader()) { 147 yield await logEntryToHistoryEntry(entry) 148 } 149} 150 151export type TimestampedHistoryEntry = { 152 display: string 153 timestamp: number 154 resolve: () => Promise<HistoryEntry> 155} 156 157/** 158 * Current-project history for the ctrl+r picker: deduped by display text, 159 * newest first, with timestamps. Paste contents are resolved lazily via 160 * `resolve()` — the picker only reads display+timestamp for the list. 161 */ 162export async function* getTimestampedHistory(): AsyncGenerator<TimestampedHistoryEntry> { 163 const currentProject = getProjectRoot() 164 const seen = new Set<string>() 165 166 for await (const entry of makeLogEntryReader()) { 167 if (!entry || typeof entry.project !== 'string') continue 168 if (entry.project !== currentProject) continue 169 if (seen.has(entry.display)) continue 170 seen.add(entry.display) 171 172 yield { 173 display: entry.display, 174 timestamp: entry.timestamp, 175 resolve: () => logEntryToHistoryEntry(entry), 176 } 177 178 if (seen.size >= MAX_HISTORY_ITEMS) return 179 } 180} 181 182/** 183 * Get history entries for the current project, with current session's entries first. 184 * 185 * Entries from the current session are yielded before entries from other sessions, 186 * so concurrent sessions don't interleave their up-arrow history. Within each group, 187 * order is newest-first. Scans the same MAX_HISTORY_ITEMS window as before — 188 * entries are reordered within that window, not beyond it. 189 */ 190export async function* getHistory(): AsyncGenerator<HistoryEntry> { 191 const currentProject = getProjectRoot() 192 const currentSession = getSessionId() 193 const otherSessionEntries: LogEntry[] = [] 194 let yielded = 0 195 196 for await (const entry of makeLogEntryReader()) { 197 // Skip malformed entries (corrupted file, old format, or invalid JSON structure) 198 if (!entry || typeof entry.project !== 'string') continue 199 if (entry.project !== currentProject) continue 200 201 if (entry.sessionId === currentSession) { 202 yield await logEntryToHistoryEntry(entry) 203 yielded++ 204 } else { 205 otherSessionEntries.push(entry) 206 } 207 208 // Same MAX_HISTORY_ITEMS window as before — just reordered within it. 209 if (yielded + otherSessionEntries.length >= MAX_HISTORY_ITEMS) break 210 } 211 212 for (const entry of otherSessionEntries) { 213 if (yielded >= MAX_HISTORY_ITEMS) return 214 yield await logEntryToHistoryEntry(entry) 215 yielded++ 216 } 217} 218 219type LogEntry = { 220 display: string 221 pastedContents: Record<number, StoredPastedContent> 222 timestamp: number 223 project: string 224 sessionId?: string 225} 226 227/** 228 * Resolve stored paste content to full PastedContent by fetching from paste store if needed. 229 */ 230async function resolveStoredPastedContent( 231 stored: StoredPastedContent, 232): Promise<PastedContent | null> { 233 // If we have inline content, use it directly 234 if (stored.content) { 235 return { 236 id: stored.id, 237 type: stored.type, 238 content: stored.content, 239 mediaType: stored.mediaType, 240 filename: stored.filename, 241 } 242 } 243 244 // If we have a hash reference, fetch from paste store 245 if (stored.contentHash) { 246 const content = await retrievePastedText(stored.contentHash) 247 if (content) { 248 return { 249 id: stored.id, 250 type: stored.type, 251 content, 252 mediaType: stored.mediaType, 253 filename: stored.filename, 254 } 255 } 256 } 257 258 // Content not available 259 return null 260} 261 262/** 263 * Convert LogEntry to HistoryEntry by resolving paste store references. 264 */ 265async function logEntryToHistoryEntry(entry: LogEntry): Promise<HistoryEntry> { 266 const pastedContents: Record<number, PastedContent> = {} 267 268 for (const [id, stored] of Object.entries(entry.pastedContents || {})) { 269 const resolved = await resolveStoredPastedContent(stored) 270 if (resolved) { 271 pastedContents[Number(id)] = resolved 272 } 273 } 274 275 return { 276 display: entry.display, 277 pastedContents, 278 } 279} 280 281let pendingEntries: LogEntry[] = [] 282let isWriting = false 283let currentFlushPromise: Promise<void> | null = null 284let cleanupRegistered = false 285let lastAddedEntry: LogEntry | null = null 286// Timestamps of entries already flushed to disk that should be skipped when 287// reading. Used by removeLastFromHistory when the entry has raced past the 288// pending buffer. Session-scoped (module state resets on process restart). 289const skippedTimestamps = new Set<number>() 290 291// Core flush logic - writes pending entries to disk 292async function immediateFlushHistory(): Promise<void> { 293 if (pendingEntries.length === 0) { 294 return 295 } 296 297 let release 298 try { 299 const historyPath = join(getClaudeConfigHomeDir(), 'history.jsonl') 300 301 // Ensure the file exists before acquiring lock (append mode creates if missing) 302 await writeFile(historyPath, '', { 303 encoding: 'utf8', 304 mode: 0o600, 305 flag: 'a', 306 }) 307 308 release = await lock(historyPath, { 309 stale: 10000, 310 retries: { 311 retries: 3, 312 minTimeout: 50, 313 }, 314 }) 315 316 const jsonLines = pendingEntries.map(entry => jsonStringify(entry) + '\n') 317 pendingEntries = [] 318 319 await appendFile(historyPath, jsonLines.join(''), { mode: 0o600 }) 320 } catch (error) { 321 logForDebugging(`Failed to write prompt history: ${error}`) 322 } finally { 323 if (release) { 324 await release() 325 } 326 } 327} 328 329async function flushPromptHistory(retries: number): Promise<void> { 330 if (isWriting || pendingEntries.length === 0) { 331 return 332 } 333 334 // Stop trying to flush history until the next user prompt 335 if (retries > 5) { 336 return 337 } 338 339 isWriting = true 340 341 try { 342 await immediateFlushHistory() 343 } finally { 344 isWriting = false 345 346 if (pendingEntries.length > 0) { 347 // Avoid trying again in a hot loop 348 await sleep(500) 349 350 void flushPromptHistory(retries + 1) 351 } 352 } 353} 354 355async function addToPromptHistory( 356 command: HistoryEntry | string, 357): Promise<void> { 358 const entry = 359 typeof command === 'string' 360 ? { display: command, pastedContents: {} } 361 : command 362 363 const storedPastedContents: Record<number, StoredPastedContent> = {} 364 if (entry.pastedContents) { 365 for (const [id, content] of Object.entries(entry.pastedContents)) { 366 // Filter out images (they're stored separately in image-cache) 367 if (content.type === 'image') { 368 continue 369 } 370 371 // For small text content, store inline 372 if (content.content.length <= MAX_PASTED_CONTENT_LENGTH) { 373 storedPastedContents[Number(id)] = { 374 id: content.id, 375 type: content.type, 376 content: content.content, 377 mediaType: content.mediaType, 378 filename: content.filename, 379 } 380 } else { 381 // For large text content, compute hash synchronously and store reference 382 // The actual disk write happens async (fire-and-forget) 383 const hash = hashPastedText(content.content) 384 storedPastedContents[Number(id)] = { 385 id: content.id, 386 type: content.type, 387 contentHash: hash, 388 mediaType: content.mediaType, 389 filename: content.filename, 390 } 391 // Fire-and-forget disk write - don't block history entry creation 392 void storePastedText(hash, content.content) 393 } 394 } 395 } 396 397 const logEntry: LogEntry = { 398 ...entry, 399 pastedContents: storedPastedContents, 400 timestamp: Date.now(), 401 project: getProjectRoot(), 402 sessionId: getSessionId(), 403 } 404 405 pendingEntries.push(logEntry) 406 lastAddedEntry = logEntry 407 currentFlushPromise = flushPromptHistory(0) 408 void currentFlushPromise 409} 410 411export function addToHistory(command: HistoryEntry | string): void { 412 // Skip history when running in a tmux session spawned by Claude Code's Tungsten tool. 413 // This prevents verification/test sessions from polluting the user's real command history. 414 if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY)) { 415 return 416 } 417 418 // Register cleanup on first use 419 if (!cleanupRegistered) { 420 cleanupRegistered = true 421 registerCleanup(async () => { 422 // If there's an in-progress flush, wait for it 423 if (currentFlushPromise) { 424 await currentFlushPromise 425 } 426 // If there are still pending entries after the flush completed, do one final flush 427 if (pendingEntries.length > 0) { 428 await immediateFlushHistory() 429 } 430 }) 431 } 432 433 void addToPromptHistory(command) 434} 435 436export function clearPendingHistoryEntries(): void { 437 pendingEntries = [] 438 lastAddedEntry = null 439 skippedTimestamps.clear() 440} 441 442/** 443 * Undo the most recent addToHistory call. Used by auto-restore-on-interrupt: 444 * when Esc rewinds the conversation before any response arrives, the submit is 445 * semantically undone — the history entry should be too, otherwise Up-arrow 446 * shows the restored text twice (once from the input box, once from disk). 447 * 448 * Fast path pops from the pending buffer. If the async flush already won the 449 * race (TTFT is typically >> disk write latency), the entry's timestamp is 450 * added to a skip-set consulted by getHistory. One-shot: clears the tracked 451 * entry so a second call is a no-op. 452 */ 453export function removeLastFromHistory(): void { 454 if (!lastAddedEntry) return 455 const entry = lastAddedEntry 456 lastAddedEntry = null 457 458 const idx = pendingEntries.lastIndexOf(entry) 459 if (idx !== -1) { 460 pendingEntries.splice(idx, 1) 461 } else { 462 skippedTimestamps.add(entry.timestamp) 463 } 464}