/ utils / debug.ts
debug.ts
  1  import { appendFile, mkdir, symlink, unlink } from 'fs/promises'
  2  import memoize from 'lodash-es/memoize.js'
  3  import { dirname, join } from 'path'
  4  import { getSessionId } from 'src/bootstrap/state.js'
  5  
  6  import { type BufferedWriter, createBufferedWriter } from './bufferedWriter.js'
  7  import { registerCleanup } from './cleanupRegistry.js'
  8  import {
  9    type DebugFilter,
 10    parseDebugFilter,
 11    shouldShowDebugMessage,
 12  } from './debugFilter.js'
 13  import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
 14  import { getFsImplementation } from './fsOperations.js'
 15  import { writeToStderr } from './process.js'
 16  import { jsonStringify } from './slowOperations.js'
 17  
 18  export type DebugLogLevel = 'verbose' | 'debug' | 'info' | 'warn' | 'error'
 19  
 20  const LEVEL_ORDER: Record<DebugLogLevel, number> = {
 21    verbose: 0,
 22    debug: 1,
 23    info: 2,
 24    warn: 3,
 25    error: 4,
 26  }
 27  
 28  /**
 29   * Minimum log level to include in debug output. Defaults to 'debug', which
 30   * filters out 'verbose' messages. Set CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose to
 31   * include high-volume diagnostics (e.g. full statusLine command, shell, cwd,
 32   * stdout/stderr) that would otherwise drown out useful debug output.
 33   */
 34  export const getMinDebugLogLevel = memoize((): DebugLogLevel => {
 35    const raw = process.env.CLAUDE_CODE_DEBUG_LOG_LEVEL?.toLowerCase().trim()
 36    if (raw && Object.hasOwn(LEVEL_ORDER, raw)) {
 37      return raw as DebugLogLevel
 38    }
 39    return 'debug'
 40  })
 41  
 42  let runtimeDebugEnabled = false
 43  
 44  export const isDebugMode = memoize((): boolean => {
 45    return (
 46      runtimeDebugEnabled ||
 47      isEnvTruthy(process.env.DEBUG) ||
 48      isEnvTruthy(process.env.DEBUG_SDK) ||
 49      process.argv.includes('--debug') ||
 50      process.argv.includes('-d') ||
 51      isDebugToStdErr() ||
 52      // Also check for --debug=pattern syntax
 53      process.argv.some(arg => arg.startsWith('--debug=')) ||
 54      // --debug-file implicitly enables debug mode
 55      getDebugFilePath() !== null
 56    )
 57  })
 58  
 59  /**
 60   * Enables debug logging mid-session (e.g. via /debug). Non-ants don't write
 61   * debug logs by default, so this lets them start capturing without restarting
 62   * with --debug. Returns true if logging was already active.
 63   */
 64  export function enableDebugLogging(): boolean {
 65    const wasActive = isDebugMode() || process.env.USER_TYPE === 'ant'
 66    runtimeDebugEnabled = true
 67    isDebugMode.cache.clear?.()
 68    return wasActive
 69  }
 70  
 71  // Extract and parse debug filter from command line arguments
 72  // Exported for testing purposes
 73  export const getDebugFilter = memoize((): DebugFilter | null => {
 74    // Look for --debug=pattern in argv
 75    const debugArg = process.argv.find(arg => arg.startsWith('--debug='))
 76    if (!debugArg) {
 77      return null
 78    }
 79  
 80    // Extract the pattern after the equals sign
 81    const filterPattern = debugArg.substring('--debug='.length)
 82    return parseDebugFilter(filterPattern)
 83  })
 84  
 85  export const isDebugToStdErr = memoize((): boolean => {
 86    return (
 87      process.argv.includes('--debug-to-stderr') || process.argv.includes('-d2e')
 88    )
 89  })
 90  
 91  export const getDebugFilePath = memoize((): string | null => {
 92    for (let i = 0; i < process.argv.length; i++) {
 93      const arg = process.argv[i]!
 94      if (arg.startsWith('--debug-file=')) {
 95        return arg.substring('--debug-file='.length)
 96      }
 97      if (arg === '--debug-file' && i + 1 < process.argv.length) {
 98        return process.argv[i + 1]!
 99      }
100    }
101    return null
102  })
103  
104  function shouldLogDebugMessage(message: string): boolean {
105    if (process.env.NODE_ENV === 'test' && !isDebugToStdErr()) {
106      return false
107    }
108  
109    // Non-ants only write debug logs when debug mode is active (via --debug at
110    // startup or /debug mid-session). Ants always log for /share, bug reports.
111    if (process.env.USER_TYPE !== 'ant' && !isDebugMode()) {
112      return false
113    }
114  
115    if (
116      typeof process === 'undefined' ||
117      typeof process.versions === 'undefined' ||
118      typeof process.versions.node === 'undefined'
119    ) {
120      return false
121    }
122  
123    const filter = getDebugFilter()
124    return shouldShowDebugMessage(message, filter)
125  }
126  
127  let hasFormattedOutput = false
128  export function setHasFormattedOutput(value: boolean): void {
129    hasFormattedOutput = value
130  }
131  export function getHasFormattedOutput(): boolean {
132    return hasFormattedOutput
133  }
134  
135  let debugWriter: BufferedWriter | null = null
136  let pendingWrite: Promise<void> = Promise.resolve()
137  
138  // Module-level so .bind captures only its explicit args, not the
139  // writeFn closure's parent scope (Jarred, #22257).
140  async function appendAsync(
141    needMkdir: boolean,
142    dir: string,
143    path: string,
144    content: string,
145  ): Promise<void> {
146    if (needMkdir) {
147      await mkdir(dir, { recursive: true }).catch(() => {})
148    }
149    await appendFile(path, content)
150    void updateLatestDebugLogSymlink()
151  }
152  
153  function noop(): void {}
154  
155  function getDebugWriter(): BufferedWriter {
156    if (!debugWriter) {
157      let ensuredDir: string | null = null
158      debugWriter = createBufferedWriter({
159        writeFn: content => {
160          const path = getDebugLogPath()
161          const dir = dirname(path)
162          const needMkdir = ensuredDir !== dir
163          ensuredDir = dir
164          if (isDebugMode()) {
165            // immediateMode: must stay sync. Async writes are lost on direct
166            // process.exit() and keep the event loop alive in beforeExit
167            // handlers (infinite loop with Perfetto tracing). See #22257.
168            if (needMkdir) {
169              try {
170                getFsImplementation().mkdirSync(dir)
171              } catch {
172                // Directory already exists
173              }
174            }
175            getFsImplementation().appendFileSync(path, content)
176            void updateLatestDebugLogSymlink()
177            return
178          }
179          // Buffered path (ants without --debug): flushes ~1/sec so chain
180          // depth stays ~1. .bind over a closure so only the bound args are
181          // retained, not this scope.
182          pendingWrite = pendingWrite
183            .then(appendAsync.bind(null, needMkdir, dir, path, content))
184            .catch(noop)
185        },
186        flushIntervalMs: 1000,
187        maxBufferSize: 100,
188        immediateMode: isDebugMode(),
189      })
190      registerCleanup(async () => {
191        debugWriter?.dispose()
192        await pendingWrite
193      })
194    }
195    return debugWriter
196  }
197  
198  export async function flushDebugLogs(): Promise<void> {
199    debugWriter?.flush()
200    await pendingWrite
201  }
202  
203  export function logForDebugging(
204    message: string,
205    { level }: { level: DebugLogLevel } = {
206      level: 'debug',
207    },
208  ): void {
209    if (LEVEL_ORDER[level] < LEVEL_ORDER[getMinDebugLogLevel()]) {
210      return
211    }
212    if (!shouldLogDebugMessage(message)) {
213      return
214    }
215  
216    // Multiline messages break the jsonl output format, so make any multiline messages JSON.
217    if (hasFormattedOutput && message.includes('\n')) {
218      message = jsonStringify(message)
219    }
220    const timestamp = new Date().toISOString()
221    const output = `${timestamp} [${level.toUpperCase()}] ${message.trim()}\n`
222    if (isDebugToStdErr()) {
223      writeToStderr(output)
224      return
225    }
226  
227    getDebugWriter().write(output)
228  }
229  
230  export function getDebugLogPath(): string {
231    return (
232      getDebugFilePath() ??
233      process.env.CLAUDE_CODE_DEBUG_LOGS_DIR ??
234      join(getClaudeConfigHomeDir(), 'debug', `${getSessionId()}.txt`)
235    )
236  }
237  
238  /**
239   * Updates the latest debug log symlink to point to the current debug log file.
240   * Creates or updates a symlink at ~/.claude/debug/latest
241   */
242  const updateLatestDebugLogSymlink = memoize(async (): Promise<void> => {
243    try {
244      const debugLogPath = getDebugLogPath()
245      const debugLogsDir = dirname(debugLogPath)
246      const latestSymlinkPath = join(debugLogsDir, 'latest')
247  
248      await unlink(latestSymlinkPath).catch(() => {})
249      await symlink(debugLogPath, latestSymlinkPath)
250    } catch {
251      // Silently fail if symlink creation fails
252    }
253  })
254  
255  /**
256   * Logs errors for Ants only, always visible in production.
257   */
258  export function logAntError(context: string, error: unknown): void {
259    if (process.env.USER_TYPE !== 'ant') {
260      return
261    }
262  
263    if (error instanceof Error && error.stack) {
264      logForDebugging(`[ANT-ONLY] ${context} stack trace:\n${error.stack}`, {
265        level: 'error',
266      })
267    }
268  }