import { feature } from 'bun:bundle' import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' import { readdir, readFile, stat } from 'fs/promises' import memoize from 'lodash-es/memoize.js' import { join } from 'path' import type { QuerySource } from 'src/constants/querySource.js' import { setLastAPIRequest, setLastAPIRequestMessages, } from '../bootstrap/state.js' import { TICK_TAG } from '../constants/xml.js' import { type LogOption, type SerializedMessage, sortLogs, } from '../types/logs.js' import { CACHE_PATHS } from './cachePaths.js' import { stripDisplayTags, stripDisplayTagsAllowEmpty } from './displayTags.js' import { isEnvTruthy } from './envUtils.js' import { toError } from './errors.js' import { isEssentialTrafficOnly } from './privacyLevel.js' import { jsonParse } from './slowOperations.js' /** * Gets the display title for a log/session with fallback logic. * Skips firstPrompt if it starts with a tick/goal tag (autonomous mode auto-prompt). * Strips display-unfriendly tags (like ) from the result. * Falls back to a truncated session ID when no other title is available. */ export function getLogDisplayTitle( log: LogOption, defaultTitle?: string, ): string { // Skip firstPrompt if it's a tick/goal message (autonomous mode auto-prompt) const isAutonomousPrompt = log.firstPrompt?.startsWith(`<${TICK_TAG}>`) // Strip display-unfriendly tags (command-name, ide_opened_file, etc.) early // so that command-only prompts (e.g. /clear) become empty and fall through // to the next fallback instead of showing raw XML tags. // Note: stripDisplayTags returns the original when stripping yields empty, // so we call stripDisplayTagsAllowEmpty to detect command-only prompts. const strippedFirstPrompt = log.firstPrompt ? stripDisplayTagsAllowEmpty(log.firstPrompt) : '' const useFirstPrompt = strippedFirstPrompt && !isAutonomousPrompt const title = log.agentName || log.customTitle || log.summary || (useFirstPrompt ? strippedFirstPrompt : undefined) || defaultTitle || // For autonomous sessions without other context, show a meaningful label (isAutonomousPrompt ? 'Autonomous session' : undefined) || // Fall back to truncated session ID for lite logs with no metadata (log.sessionId ? log.sessionId.slice(0, 8) : '') || '' // Strip display-unfriendly tags (like ) for cleaner titles return stripDisplayTags(title).trim() } export function dateToFilename(date: Date): string { return date.toISOString().replace(/[:.]/g, '-') } // In-memory error log for recent errors // Moved from bootstrap/state.ts to break import cycle const MAX_IN_MEMORY_ERRORS = 100 let inMemoryErrorLog: Array<{ error: string; timestamp: string }> = [] function addToInMemoryErrorLog(errorInfo: { error: string timestamp: string }): void { if (inMemoryErrorLog.length >= MAX_IN_MEMORY_ERRORS) { inMemoryErrorLog.shift() // Remove oldest error } inMemoryErrorLog.push(errorInfo) } /** * Sink interface for the error logging backend */ export type ErrorLogSink = { logError: (error: Error) => void logMCPError: (serverName: string, error: unknown) => void logMCPDebug: (serverName: string, message: string) => void getErrorsPath: () => string getMCPLogsPath: (serverName: string) => string } // Queued events for events logged before sink is attached type QueuedErrorEvent = | { type: 'error'; error: Error } | { type: 'mcpError'; serverName: string; error: unknown } | { type: 'mcpDebug'; serverName: string; message: string } const errorQueue: QueuedErrorEvent[] = [] // Sink - initialized during app startup let errorLogSink: ErrorLogSink | null = null /** * Attach the error log sink that will receive all error events. * Queued events are drained immediately to ensure no errors are lost. * * Idempotent: if a sink is already attached, this is a no-op. This allows * calling from both the preAction hook (for subcommands) and setup() (for * the default command) without coordination. */ export function attachErrorLogSink(newSink: ErrorLogSink): void { if (errorLogSink !== null) { return } errorLogSink = newSink // Drain the queue immediately - errors should not be delayed if (errorQueue.length > 0) { const queuedEvents = [...errorQueue] errorQueue.length = 0 for (const event of queuedEvents) { switch (event.type) { case 'error': errorLogSink.logError(event.error) break case 'mcpError': errorLogSink.logMCPError(event.serverName, event.error) break case 'mcpDebug': errorLogSink.logMCPDebug(event.serverName, event.message) break } } } } /** * Logs an error to multiple destinations for debugging and monitoring. * * This function logs errors to: * - Debug logs (visible via `claude --debug` or `tail -f ~/.claude/debug/latest`) * - In-memory error log (accessible via `getInMemoryErrors()`, useful for including * in bug reports or displaying recent errors to users) * - Persistent error log file (only for internal 'ant' users, stored in ~/.claude/errors/) * * Usage: * ```ts * logError(new Error('Failed to connect')) * ``` * * To view errors: * - Debug: Run `claude --debug` or `tail -f ~/.claude/debug/latest` * - In-memory: Call `getInMemoryErrors()` to get recent errors for the current session */ const isHardFailMode = memoize((): boolean => { return process.argv.includes('--hard-fail') }) export function logError(error: unknown): void { const err = toError(error) if (feature('HARD_FAIL') && isHardFailMode()) { // biome-ignore lint/suspicious/noConsole:: intentional crash output console.error('[HARD FAIL] logError called with:', err.stack || err.message) // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) } try { // Check if error reporting should be disabled if ( // Cloud providers (Bedrock/Vertex/Foundry) always disable features isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || process.env.DISABLE_ERROR_REPORTING || isEssentialTrafficOnly() ) { return } const errorStr = err.stack || err.message const errorInfo = { error: errorStr, timestamp: new Date().toISOString(), } // Always add to in-memory log (no dependencies needed) addToInMemoryErrorLog(errorInfo) // If sink not attached, queue the event if (errorLogSink === null) { errorQueue.push({ type: 'error', error: err }) return } errorLogSink.logError(err) } catch { // pass } } export function getInMemoryErrors(): { error: string; timestamp: string }[] { return [...inMemoryErrorLog] } /** * Loads the list of error logs * @returns List of error logs sorted by date */ export function loadErrorLogs(): Promise { return loadLogList(CACHE_PATHS.errors()) } /** * Gets an error log by its index * @param index Index in the sorted list of logs (0-based) * @returns Log data or null if not found */ export async function getErrorLogByIndex( index: number, ): Promise { const logs = await loadErrorLogs() return logs[index] || null } /** * Internal function to load and process logs from a specified path * @param path Directory containing logs * @returns Array of logs sorted by date * @private */ async function loadLogList(path: string): Promise { let files: Awaited> try { files = await readdir(path, { withFileTypes: true }) } catch { logError(new Error(`No logs found at ${path}`)) return [] } const logData = await Promise.all( files.map(async (file, i) => { const fullPath = join(path, file.name) const content = await readFile(fullPath, { encoding: 'utf8' }) const messages = jsonParse(content) as SerializedMessage[] const firstMessage = messages[0] const lastMessage = messages[messages.length - 1] const firstPrompt = firstMessage?.type === 'user' && typeof firstMessage?.message?.content === 'string' ? firstMessage?.message?.content : 'No prompt' // For new random filenames, we'll get stats from the file itself const fileStats = await stat(fullPath) // Check if it's a sidechain by looking at filename const isSidechain = fullPath.includes('sidechain') // For new files, use the file modified time as date const date = dateToFilename(fileStats.mtime) return { date, fullPath, messages, value: i, // hack: overwritten after sorting, right below this created: parseISOString(firstMessage?.timestamp || date), modified: lastMessage?.timestamp ? parseISOString(lastMessage.timestamp) : parseISOString(date), firstPrompt: firstPrompt.split('\n')[0]?.slice(0, 50) + (firstPrompt.length > 50 ? '…' : '') || 'No prompt', messageCount: messages.length, isSidechain, } }), ) return sortLogs(logData.filter(_ => _ !== null)).map((_, i) => ({ ..._, value: i, })) } function parseISOString(s: string): Date { const b = s.split(/\D+/) return new Date( Date.UTC( parseInt(b[0]!, 10), parseInt(b[1]!, 10) - 1, parseInt(b[2]!, 10), parseInt(b[3]!, 10), parseInt(b[4]!, 10), parseInt(b[5]!, 10), parseInt(b[6]!, 10), ), ) } export function logMCPError(serverName: string, error: unknown): void { try { // If sink not attached, queue the event if (errorLogSink === null) { errorQueue.push({ type: 'mcpError', serverName, error }) return } errorLogSink.logMCPError(serverName, error) } catch { // Silently fail } } export function logMCPDebug(serverName: string, message: string): void { try { // If sink not attached, queue the event if (errorLogSink === null) { errorQueue.push({ type: 'mcpDebug', serverName, message }) return } errorLogSink.logMCPDebug(serverName, message) } catch { // Silently fail } } /** * Captures the last API request for inclusion in bug reports. */ export function captureAPIRequest( params: BetaMessageStreamParams, querySource?: QuerySource, ): void { // startsWith, not exact match — users with non-default output styles get // variants like 'repl_main_thread:outputStyle:Explanatory' (querySource.ts). if (!querySource || !querySource.startsWith('repl_main_thread')) { return } // Store params WITHOUT messages to avoid retaining the entire conversation // for all users. Messages are already persisted to the transcript file and // available via React state. const { messages, ...paramsWithoutMessages } = params setLastAPIRequest(paramsWithoutMessages) // For ant users only: also keep a reference to the final messages array so // /share's serialized_conversation.json captures the exact post-compaction, // CLAUDE.md-injected payload the API received. Overwritten each turn; // dumpPrompts.ts already holds 5 full request bodies for ants, so this is // not a new retention class. setLastAPIRequestMessages(process.env.USER_TYPE === 'ant' ? messages : null) } /** * Reset error log state for testing purposes only. * @internal */ export function _resetErrorLogForTesting(): void { errorLogSink = null errorQueue.length = 0 inMemoryErrorLog = [] }