init claude-code
This commit is contained in:
+362
@@ -0,0 +1,362 @@
|
||||
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 <ide_opened_file>) 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 <ide_opened_file>) 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<LogOption[]> {
|
||||
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<LogOption | null> {
|
||||
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<LogOption[]> {
|
||||
let files: Awaited<ReturnType<typeof readdir>>
|
||||
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 = []
|
||||
}
|
||||
Reference in New Issue
Block a user