source dump of claude code
at main 362 lines 12 kB view raw
1import { feature } from 'bun:bundle' 2import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 3import { readdir, readFile, stat } from 'fs/promises' 4import memoize from 'lodash-es/memoize.js' 5import { join } from 'path' 6import type { QuerySource } from 'src/constants/querySource.js' 7import { 8 setLastAPIRequest, 9 setLastAPIRequestMessages, 10} from '../bootstrap/state.js' 11import { TICK_TAG } from '../constants/xml.js' 12import { 13 type LogOption, 14 type SerializedMessage, 15 sortLogs, 16} from '../types/logs.js' 17import { CACHE_PATHS } from './cachePaths.js' 18import { stripDisplayTags, stripDisplayTagsAllowEmpty } from './displayTags.js' 19import { isEnvTruthy } from './envUtils.js' 20import { toError } from './errors.js' 21import { isEssentialTrafficOnly } from './privacyLevel.js' 22import { jsonParse } from './slowOperations.js' 23 24/** 25 * Gets the display title for a log/session with fallback logic. 26 * Skips firstPrompt if it starts with a tick/goal tag (autonomous mode auto-prompt). 27 * Strips display-unfriendly tags (like <ide_opened_file>) from the result. 28 * Falls back to a truncated session ID when no other title is available. 29 */ 30export function getLogDisplayTitle( 31 log: LogOption, 32 defaultTitle?: string, 33): string { 34 // Skip firstPrompt if it's a tick/goal message (autonomous mode auto-prompt) 35 const isAutonomousPrompt = log.firstPrompt?.startsWith(`<${TICK_TAG}>`) 36 // Strip display-unfriendly tags (command-name, ide_opened_file, etc.) early 37 // so that command-only prompts (e.g. /clear) become empty and fall through 38 // to the next fallback instead of showing raw XML tags. 39 // Note: stripDisplayTags returns the original when stripping yields empty, 40 // so we call stripDisplayTagsAllowEmpty to detect command-only prompts. 41 const strippedFirstPrompt = log.firstPrompt 42 ? stripDisplayTagsAllowEmpty(log.firstPrompt) 43 : '' 44 const useFirstPrompt = strippedFirstPrompt && !isAutonomousPrompt 45 const title = 46 log.agentName || 47 log.customTitle || 48 log.summary || 49 (useFirstPrompt ? strippedFirstPrompt : undefined) || 50 defaultTitle || 51 // For autonomous sessions without other context, show a meaningful label 52 (isAutonomousPrompt ? 'Autonomous session' : undefined) || 53 // Fall back to truncated session ID for lite logs with no metadata 54 (log.sessionId ? log.sessionId.slice(0, 8) : '') || 55 '' 56 // Strip display-unfriendly tags (like <ide_opened_file>) for cleaner titles 57 return stripDisplayTags(title).trim() 58} 59 60export function dateToFilename(date: Date): string { 61 return date.toISOString().replace(/[:.]/g, '-') 62} 63 64// In-memory error log for recent errors 65// Moved from bootstrap/state.ts to break import cycle 66const MAX_IN_MEMORY_ERRORS = 100 67let inMemoryErrorLog: Array<{ error: string; timestamp: string }> = [] 68 69function addToInMemoryErrorLog(errorInfo: { 70 error: string 71 timestamp: string 72}): void { 73 if (inMemoryErrorLog.length >= MAX_IN_MEMORY_ERRORS) { 74 inMemoryErrorLog.shift() // Remove oldest error 75 } 76 inMemoryErrorLog.push(errorInfo) 77} 78 79/** 80 * Sink interface for the error logging backend 81 */ 82export type ErrorLogSink = { 83 logError: (error: Error) => void 84 logMCPError: (serverName: string, error: unknown) => void 85 logMCPDebug: (serverName: string, message: string) => void 86 getErrorsPath: () => string 87 getMCPLogsPath: (serverName: string) => string 88} 89 90// Queued events for events logged before sink is attached 91type QueuedErrorEvent = 92 | { type: 'error'; error: Error } 93 | { type: 'mcpError'; serverName: string; error: unknown } 94 | { type: 'mcpDebug'; serverName: string; message: string } 95 96const errorQueue: QueuedErrorEvent[] = [] 97 98// Sink - initialized during app startup 99let errorLogSink: ErrorLogSink | null = null 100 101/** 102 * Attach the error log sink that will receive all error events. 103 * Queued events are drained immediately to ensure no errors are lost. 104 * 105 * Idempotent: if a sink is already attached, this is a no-op. This allows 106 * calling from both the preAction hook (for subcommands) and setup() (for 107 * the default command) without coordination. 108 */ 109export function attachErrorLogSink(newSink: ErrorLogSink): void { 110 if (errorLogSink !== null) { 111 return 112 } 113 errorLogSink = newSink 114 115 // Drain the queue immediately - errors should not be delayed 116 if (errorQueue.length > 0) { 117 const queuedEvents = [...errorQueue] 118 errorQueue.length = 0 119 120 for (const event of queuedEvents) { 121 switch (event.type) { 122 case 'error': 123 errorLogSink.logError(event.error) 124 break 125 case 'mcpError': 126 errorLogSink.logMCPError(event.serverName, event.error) 127 break 128 case 'mcpDebug': 129 errorLogSink.logMCPDebug(event.serverName, event.message) 130 break 131 } 132 } 133 } 134} 135 136/** 137 * Logs an error to multiple destinations for debugging and monitoring. 138 * 139 * This function logs errors to: 140 * - Debug logs (visible via `claude --debug` or `tail -f ~/.claude/debug/latest`) 141 * - In-memory error log (accessible via `getInMemoryErrors()`, useful for including 142 * in bug reports or displaying recent errors to users) 143 * - Persistent error log file (only for internal 'ant' users, stored in ~/.claude/errors/) 144 * 145 * Usage: 146 * ```ts 147 * logError(new Error('Failed to connect')) 148 * ``` 149 * 150 * To view errors: 151 * - Debug: Run `claude --debug` or `tail -f ~/.claude/debug/latest` 152 * - In-memory: Call `getInMemoryErrors()` to get recent errors for the current session 153 */ 154const isHardFailMode = memoize((): boolean => { 155 return process.argv.includes('--hard-fail') 156}) 157 158export function logError(error: unknown): void { 159 const err = toError(error) 160 if (feature('HARD_FAIL') && isHardFailMode()) { 161 // biome-ignore lint/suspicious/noConsole:: intentional crash output 162 console.error('[HARD FAIL] logError called with:', err.stack || err.message) 163 // eslint-disable-next-line custom-rules/no-process-exit 164 process.exit(1) 165 } 166 try { 167 // Check if error reporting should be disabled 168 if ( 169 // Cloud providers (Bedrock/Vertex/Foundry) always disable features 170 isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || 171 isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || 172 isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || 173 process.env.DISABLE_ERROR_REPORTING || 174 isEssentialTrafficOnly() 175 ) { 176 return 177 } 178 179 const errorStr = err.stack || err.message 180 181 const errorInfo = { 182 error: errorStr, 183 timestamp: new Date().toISOString(), 184 } 185 186 // Always add to in-memory log (no dependencies needed) 187 addToInMemoryErrorLog(errorInfo) 188 189 // If sink not attached, queue the event 190 if (errorLogSink === null) { 191 errorQueue.push({ type: 'error', error: err }) 192 return 193 } 194 195 errorLogSink.logError(err) 196 } catch { 197 // pass 198 } 199} 200 201export function getInMemoryErrors(): { error: string; timestamp: string }[] { 202 return [...inMemoryErrorLog] 203} 204 205/** 206 * Loads the list of error logs 207 * @returns List of error logs sorted by date 208 */ 209export function loadErrorLogs(): Promise<LogOption[]> { 210 return loadLogList(CACHE_PATHS.errors()) 211} 212 213/** 214 * Gets an error log by its index 215 * @param index Index in the sorted list of logs (0-based) 216 * @returns Log data or null if not found 217 */ 218export async function getErrorLogByIndex( 219 index: number, 220): Promise<LogOption | null> { 221 const logs = await loadErrorLogs() 222 return logs[index] || null 223} 224 225/** 226 * Internal function to load and process logs from a specified path 227 * @param path Directory containing logs 228 * @returns Array of logs sorted by date 229 * @private 230 */ 231async function loadLogList(path: string): Promise<LogOption[]> { 232 let files: Awaited<ReturnType<typeof readdir>> 233 try { 234 files = await readdir(path, { withFileTypes: true }) 235 } catch { 236 logError(new Error(`No logs found at ${path}`)) 237 return [] 238 } 239 const logData = await Promise.all( 240 files.map(async (file, i) => { 241 const fullPath = join(path, file.name) 242 const content = await readFile(fullPath, { encoding: 'utf8' }) 243 const messages = jsonParse(content) as SerializedMessage[] 244 const firstMessage = messages[0] 245 const lastMessage = messages[messages.length - 1] 246 const firstPrompt = 247 firstMessage?.type === 'user' && 248 typeof firstMessage?.message?.content === 'string' 249 ? firstMessage?.message?.content 250 : 'No prompt' 251 252 // For new random filenames, we'll get stats from the file itself 253 const fileStats = await stat(fullPath) 254 255 // Check if it's a sidechain by looking at filename 256 const isSidechain = fullPath.includes('sidechain') 257 258 // For new files, use the file modified time as date 259 const date = dateToFilename(fileStats.mtime) 260 261 return { 262 date, 263 fullPath, 264 messages, 265 value: i, // hack: overwritten after sorting, right below this 266 created: parseISOString(firstMessage?.timestamp || date), 267 modified: lastMessage?.timestamp 268 ? parseISOString(lastMessage.timestamp) 269 : parseISOString(date), 270 firstPrompt: 271 firstPrompt.split('\n')[0]?.slice(0, 50) + 272 (firstPrompt.length > 50 ? '…' : '') || 'No prompt', 273 messageCount: messages.length, 274 isSidechain, 275 } 276 }), 277 ) 278 279 return sortLogs(logData.filter(_ => _ !== null)).map((_, i) => ({ 280 ..._, 281 value: i, 282 })) 283} 284 285function parseISOString(s: string): Date { 286 const b = s.split(/\D+/) 287 return new Date( 288 Date.UTC( 289 parseInt(b[0]!, 10), 290 parseInt(b[1]!, 10) - 1, 291 parseInt(b[2]!, 10), 292 parseInt(b[3]!, 10), 293 parseInt(b[4]!, 10), 294 parseInt(b[5]!, 10), 295 parseInt(b[6]!, 10), 296 ), 297 ) 298} 299 300export function logMCPError(serverName: string, error: unknown): void { 301 try { 302 // If sink not attached, queue the event 303 if (errorLogSink === null) { 304 errorQueue.push({ type: 'mcpError', serverName, error }) 305 return 306 } 307 308 errorLogSink.logMCPError(serverName, error) 309 } catch { 310 // Silently fail 311 } 312} 313 314export function logMCPDebug(serverName: string, message: string): void { 315 try { 316 // If sink not attached, queue the event 317 if (errorLogSink === null) { 318 errorQueue.push({ type: 'mcpDebug', serverName, message }) 319 return 320 } 321 322 errorLogSink.logMCPDebug(serverName, message) 323 } catch { 324 // Silently fail 325 } 326} 327 328/** 329 * Captures the last API request for inclusion in bug reports. 330 */ 331export function captureAPIRequest( 332 params: BetaMessageStreamParams, 333 querySource?: QuerySource, 334): void { 335 // startsWith, not exact match — users with non-default output styles get 336 // variants like 'repl_main_thread:outputStyle:Explanatory' (querySource.ts). 337 if (!querySource || !querySource.startsWith('repl_main_thread')) { 338 return 339 } 340 341 // Store params WITHOUT messages to avoid retaining the entire conversation 342 // for all users. Messages are already persisted to the transcript file and 343 // available via React state. 344 const { messages, ...paramsWithoutMessages } = params 345 setLastAPIRequest(paramsWithoutMessages) 346 // For ant users only: also keep a reference to the final messages array so 347 // /share's serialized_conversation.json captures the exact post-compaction, 348 // CLAUDE.md-injected payload the API received. Overwritten each turn; 349 // dumpPrompts.ts already holds 5 full request bodies for ants, so this is 350 // not a new retention class. 351 setLastAPIRequestMessages(process.env.USER_TYPE === 'ant' ? messages : null) 352} 353 354/** 355 * Reset error log state for testing purposes only. 356 * @internal 357 */ 358export function _resetErrorLogForTesting(): void { 359 errorLogSink = null 360 errorQueue.length = 0 361 inMemoryErrorLog = [] 362}