init
This commit is contained in:
+268
@@ -0,0 +1,268 @@
|
||||
import { appendFile, mkdir, symlink, unlink } from 'fs/promises'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { dirname, join } from 'path'
|
||||
import { getSessionId } from 'src/bootstrap/state.js'
|
||||
|
||||
import { type BufferedWriter, createBufferedWriter } from './bufferedWriter.js'
|
||||
import { registerCleanup } from './cleanupRegistry.js'
|
||||
import {
|
||||
type DebugFilter,
|
||||
parseDebugFilter,
|
||||
shouldShowDebugMessage,
|
||||
} from './debugFilter.js'
|
||||
import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
|
||||
import { getFsImplementation } from './fsOperations.js'
|
||||
import { writeToStderr } from './process.js'
|
||||
import { jsonStringify } from './slowOperations.js'
|
||||
|
||||
export type DebugLogLevel = 'verbose' | 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
const LEVEL_ORDER: Record<DebugLogLevel, number> = {
|
||||
verbose: 0,
|
||||
debug: 1,
|
||||
info: 2,
|
||||
warn: 3,
|
||||
error: 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum log level to include in debug output. Defaults to 'debug', which
|
||||
* filters out 'verbose' messages. Set CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose to
|
||||
* include high-volume diagnostics (e.g. full statusLine command, shell, cwd,
|
||||
* stdout/stderr) that would otherwise drown out useful debug output.
|
||||
*/
|
||||
export const getMinDebugLogLevel = memoize((): DebugLogLevel => {
|
||||
const raw = process.env.CLAUDE_CODE_DEBUG_LOG_LEVEL?.toLowerCase().trim()
|
||||
if (raw && Object.hasOwn(LEVEL_ORDER, raw)) {
|
||||
return raw as DebugLogLevel
|
||||
}
|
||||
return 'debug'
|
||||
})
|
||||
|
||||
let runtimeDebugEnabled = false
|
||||
|
||||
export const isDebugMode = memoize((): boolean => {
|
||||
return (
|
||||
runtimeDebugEnabled ||
|
||||
isEnvTruthy(process.env.DEBUG) ||
|
||||
isEnvTruthy(process.env.DEBUG_SDK) ||
|
||||
process.argv.includes('--debug') ||
|
||||
process.argv.includes('-d') ||
|
||||
isDebugToStdErr() ||
|
||||
// Also check for --debug=pattern syntax
|
||||
process.argv.some(arg => arg.startsWith('--debug=')) ||
|
||||
// --debug-file implicitly enables debug mode
|
||||
getDebugFilePath() !== null
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Enables debug logging mid-session (e.g. via /debug). Non-ants don't write
|
||||
* debug logs by default, so this lets them start capturing without restarting
|
||||
* with --debug. Returns true if logging was already active.
|
||||
*/
|
||||
export function enableDebugLogging(): boolean {
|
||||
const wasActive = isDebugMode() || process.env.USER_TYPE === 'ant'
|
||||
runtimeDebugEnabled = true
|
||||
isDebugMode.cache.clear?.()
|
||||
return wasActive
|
||||
}
|
||||
|
||||
// Extract and parse debug filter from command line arguments
|
||||
// Exported for testing purposes
|
||||
export const getDebugFilter = memoize((): DebugFilter | null => {
|
||||
// Look for --debug=pattern in argv
|
||||
const debugArg = process.argv.find(arg => arg.startsWith('--debug='))
|
||||
if (!debugArg) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract the pattern after the equals sign
|
||||
const filterPattern = debugArg.substring('--debug='.length)
|
||||
return parseDebugFilter(filterPattern)
|
||||
})
|
||||
|
||||
export const isDebugToStdErr = memoize((): boolean => {
|
||||
return (
|
||||
process.argv.includes('--debug-to-stderr') || process.argv.includes('-d2e')
|
||||
)
|
||||
})
|
||||
|
||||
export const getDebugFilePath = memoize((): string | null => {
|
||||
for (let i = 0; i < process.argv.length; i++) {
|
||||
const arg = process.argv[i]!
|
||||
if (arg.startsWith('--debug-file=')) {
|
||||
return arg.substring('--debug-file='.length)
|
||||
}
|
||||
if (arg === '--debug-file' && i + 1 < process.argv.length) {
|
||||
return process.argv[i + 1]!
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function shouldLogDebugMessage(message: string): boolean {
|
||||
if (process.env.NODE_ENV === 'test' && !isDebugToStdErr()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Non-ants only write debug logs when debug mode is active (via --debug at
|
||||
// startup or /debug mid-session). Ants always log for /share, bug reports.
|
||||
if (process.env.USER_TYPE !== 'ant' && !isDebugMode()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
typeof process === 'undefined' ||
|
||||
typeof process.versions === 'undefined' ||
|
||||
typeof process.versions.node === 'undefined'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const filter = getDebugFilter()
|
||||
return shouldShowDebugMessage(message, filter)
|
||||
}
|
||||
|
||||
let hasFormattedOutput = false
|
||||
export function setHasFormattedOutput(value: boolean): void {
|
||||
hasFormattedOutput = value
|
||||
}
|
||||
export function getHasFormattedOutput(): boolean {
|
||||
return hasFormattedOutput
|
||||
}
|
||||
|
||||
let debugWriter: BufferedWriter | null = null
|
||||
let pendingWrite: Promise<void> = Promise.resolve()
|
||||
|
||||
// Module-level so .bind captures only its explicit args, not the
|
||||
// writeFn closure's parent scope (Jarred, #22257).
|
||||
async function appendAsync(
|
||||
needMkdir: boolean,
|
||||
dir: string,
|
||||
path: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
if (needMkdir) {
|
||||
await mkdir(dir, { recursive: true }).catch(() => {})
|
||||
}
|
||||
await appendFile(path, content)
|
||||
void updateLatestDebugLogSymlink()
|
||||
}
|
||||
|
||||
function noop(): void {}
|
||||
|
||||
function getDebugWriter(): BufferedWriter {
|
||||
if (!debugWriter) {
|
||||
let ensuredDir: string | null = null
|
||||
debugWriter = createBufferedWriter({
|
||||
writeFn: content => {
|
||||
const path = getDebugLogPath()
|
||||
const dir = dirname(path)
|
||||
const needMkdir = ensuredDir !== dir
|
||||
ensuredDir = dir
|
||||
if (isDebugMode()) {
|
||||
// immediateMode: must stay sync. Async writes are lost on direct
|
||||
// process.exit() and keep the event loop alive in beforeExit
|
||||
// handlers (infinite loop with Perfetto tracing). See #22257.
|
||||
if (needMkdir) {
|
||||
try {
|
||||
getFsImplementation().mkdirSync(dir)
|
||||
} catch {
|
||||
// Directory already exists
|
||||
}
|
||||
}
|
||||
getFsImplementation().appendFileSync(path, content)
|
||||
void updateLatestDebugLogSymlink()
|
||||
return
|
||||
}
|
||||
// Buffered path (ants without --debug): flushes ~1/sec so chain
|
||||
// depth stays ~1. .bind over a closure so only the bound args are
|
||||
// retained, not this scope.
|
||||
pendingWrite = pendingWrite
|
||||
.then(appendAsync.bind(null, needMkdir, dir, path, content))
|
||||
.catch(noop)
|
||||
},
|
||||
flushIntervalMs: 1000,
|
||||
maxBufferSize: 100,
|
||||
immediateMode: isDebugMode(),
|
||||
})
|
||||
registerCleanup(async () => {
|
||||
debugWriter?.dispose()
|
||||
await pendingWrite
|
||||
})
|
||||
}
|
||||
return debugWriter
|
||||
}
|
||||
|
||||
export async function flushDebugLogs(): Promise<void> {
|
||||
debugWriter?.flush()
|
||||
await pendingWrite
|
||||
}
|
||||
|
||||
export function logForDebugging(
|
||||
message: string,
|
||||
{ level }: { level: DebugLogLevel } = {
|
||||
level: 'debug',
|
||||
},
|
||||
): void {
|
||||
if (LEVEL_ORDER[level] < LEVEL_ORDER[getMinDebugLogLevel()]) {
|
||||
return
|
||||
}
|
||||
if (!shouldLogDebugMessage(message)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Multiline messages break the jsonl output format, so make any multiline messages JSON.
|
||||
if (hasFormattedOutput && message.includes('\n')) {
|
||||
message = jsonStringify(message)
|
||||
}
|
||||
const timestamp = new Date().toISOString()
|
||||
const output = `${timestamp} [${level.toUpperCase()}] ${message.trim()}\n`
|
||||
if (isDebugToStdErr()) {
|
||||
writeToStderr(output)
|
||||
return
|
||||
}
|
||||
|
||||
getDebugWriter().write(output)
|
||||
}
|
||||
|
||||
export function getDebugLogPath(): string {
|
||||
return (
|
||||
getDebugFilePath() ??
|
||||
process.env.CLAUDE_CODE_DEBUG_LOGS_DIR ??
|
||||
join(getClaudeConfigHomeDir(), 'debug', `${getSessionId()}.txt`)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the latest debug log symlink to point to the current debug log file.
|
||||
* Creates or updates a symlink at ~/.claude/debug/latest
|
||||
*/
|
||||
const updateLatestDebugLogSymlink = memoize(async (): Promise<void> => {
|
||||
try {
|
||||
const debugLogPath = getDebugLogPath()
|
||||
const debugLogsDir = dirname(debugLogPath)
|
||||
const latestSymlinkPath = join(debugLogsDir, 'latest')
|
||||
|
||||
await unlink(latestSymlinkPath).catch(() => {})
|
||||
await symlink(debugLogPath, latestSymlinkPath)
|
||||
} catch {
|
||||
// Silently fail if symlink creation fails
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Logs errors for Ants only, always visible in production.
|
||||
*/
|
||||
export function logAntError(context: string, error: unknown): void {
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
return
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.stack) {
|
||||
logForDebugging(`[ANT-ONLY] ${context} stack trace:\n${error.stack}`, {
|
||||
level: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user