/ utils / log.ts
log.ts
  1  import { feature } from 'bun:bundle'
  2  import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
  3  import { readdir, readFile, stat } from 'fs/promises'
  4  import memoize from 'lodash-es/memoize.js'
  5  import { join } from 'path'
  6  import type { QuerySource } from 'src/constants/querySource.js'
  7  import {
  8    setLastAPIRequest,
  9    setLastAPIRequestMessages,
 10  } from '../bootstrap/state.js'
 11  import { TICK_TAG } from '../constants/xml.js'
 12  import {
 13    type LogOption,
 14    type SerializedMessage,
 15    sortLogs,
 16  } from '../types/logs.js'
 17  import { CACHE_PATHS } from './cachePaths.js'
 18  import { stripDisplayTags, stripDisplayTagsAllowEmpty } from './displayTags.js'
 19  import { isEnvTruthy } from './envUtils.js'
 20  import { toError } from './errors.js'
 21  import { isEssentialTrafficOnly } from './privacyLevel.js'
 22  import { 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   */
 30  export 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  
 60  export 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
 66  const MAX_IN_MEMORY_ERRORS = 100
 67  let inMemoryErrorLog: Array<{ error: string; timestamp: string }> = []
 68  
 69  function 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   */
 82  export 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
 91  type QueuedErrorEvent =
 92    | { type: 'error'; error: Error }
 93    | { type: 'mcpError'; serverName: string; error: unknown }
 94    | { type: 'mcpDebug'; serverName: string; message: string }
 95  
 96  const errorQueue: QueuedErrorEvent[] = []
 97  
 98  // Sink - initialized during app startup
 99  let 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   */
109  export 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   */
154  const isHardFailMode = memoize((): boolean => {
155    return process.argv.includes('--hard-fail')
156  })
157  
158  export 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  
201  export 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   */
209  export 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   */
218  export 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   */
231  async 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  
285  function 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  
300  export 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  
314  export 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   */
331  export 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   */
358  export function _resetErrorLogForTesting(): void {
359    errorLogSink = null
360    errorQueue.length = 0
361    inMemoryErrorLog = []
362  }