diff --git a/packages/happy-cli/src/claude/utils/sessionScanner.ts b/packages/happy-cli/src/claude/utils/sessionScanner.ts index 4d0411a21d..adec285e3c 100644 --- a/packages/happy-cli/src/claude/utils/sessionScanner.ts +++ b/packages/happy-cli/src/claude/utils/sessionScanner.ts @@ -12,6 +12,13 @@ import { getProjectPath } from "./path"; * These are written to session JSONL files by Claude Code but are not * actual conversation messages - they're internal state/tracking events. */ +/** + * How often a session's unchanged scan summary is re-logged. The scan itself + * runs every 3s for the session's whole life; logging each one wrote ~1200 + * identical lines per hour per session (32% of all log bytes on one machine). + */ +const SUMMARY_HEARTBEAT_MS = 60_000; + const INTERNAL_CLAUDE_EVENT_TYPES = new Set([ 'file-history-snapshot', 'change', @@ -46,6 +53,11 @@ export async function createSessionScanner(opts: { let currentSessionId: string | null = null; let watchers = new Map void)>(); let processedEntryKeys = new Set(); + // Sessions whose transcript is currently absent and already reported once, + // so the 3s poll reports the absence on transition rather than every tick. + let absenceLogged = new Set(); + // Last time each session's steady-state scan summary was written. + let lastSummaryLoggedAt = new Map(); // Sessions whose transcript file never appeared. Their watcher gave up, // so we must stop re-reading them and never re-create a watcher for them // — otherwise a phantom session id (e.g. a remote launch whose .jsonl is @@ -55,7 +67,7 @@ export async function createSessionScanner(opts: { // Mark existing entries as processed and start watching the initial session if (opts.sessionId) { - let entries = await readSessionEntries(projectDir, opts.sessionId); + let entries = await readSessionEntries(projectDir, opts.sessionId) ?? []; logger.debug(`[SESSION_SCANNER] Marking ${entries.length} existing entries as processed from session ${opts.sessionId}`); for (let entry of entries) { processedEntryKeys.add(entry.key); @@ -89,7 +101,18 @@ export async function createSessionScanner(opts: { // Process sessions for (let session of sessions) { - const sessionEntries = await readSessionEntries(projectDir, session); + const readResult = await readSessionEntries(projectDir, session); + // Log the transition, not every poll: "still absent" repeated every + // 3s is what filled these logs, and it carries no new information. + if (readResult === null) { + if (!absenceLogged.has(session)) { + absenceLogged.add(session); + logger.debug(`[SESSION_SCANNER] Transcript not on disk yet for ${session} — waiting for it to be created`); + } + } else if (absenceLogged.delete(session)) { + logger.debug(`[SESSION_SCANNER] Transcript appeared for ${session}`); + } + const sessionEntries = readResult ?? []; let skipped = 0; let sentMessages = 0; let sentTranscriptEvents = 0; @@ -109,7 +132,13 @@ export async function createSessionScanner(opts: { sentTranscriptEvents++; } } - if (sessionEntries.length > 0) { + // Log when this poll actually did something; otherwise at most once + // per heartbeat, so "the scanner is alive and reading N entries" + // stays observable without writing the same line 1200 times an hour. + const movedSomething = sentMessages > 0 || sentTranscriptEvents > 0; + const now = Date.now(); + if (sessionEntries.length > 0 && (movedSomething || now - (lastSummaryLoggedAt.get(session) ?? 0) >= SUMMARY_HEARTBEAT_MS)) { + lastSummaryLoggedAt.set(session, now); logger.debug(`[SESSION_SCANNER] Session ${session}: found=${sessionEntries.length}, skipped=${skipped}, sentMessages=${sentMessages}, sentTranscriptEvents=${sentTranscriptEvents}`); } } @@ -190,7 +219,7 @@ export async function createSessionScanner(opts: { // file as fresh user prompts. Without this, every previous // user message re-appears in the chat after reconnect. if (options?.treatExistingAsProcessed) { - const existing = await readSessionEntries(projectDir, sessionId); + const existing = await readSessionEntries(projectDir, sessionId) ?? []; logger.debug(`[SESSION_SCANNER] Pre-marking ${existing.length} existing entries as processed for new session ${sessionId}`); for (const entry of existing) { processedEntryKeys.add(entry.key); @@ -236,15 +265,22 @@ function transcriptEventKey(event: ScannerTranscriptEvent): string { * Returns only valid conversation messages and recognized side-channel events, * silently skipping internal events. */ -async function readSessionEntries(projectDir: string, sessionId: string): Promise { +/** + * Returns null when the transcript could not be read at all (it is created + * lazily on the first prompt, so "absent" is a normal early state), and the + * parsed entries otherwise. + * + * Deliberately silent: this runs on the 3s poll for the whole life of a + * session, so a line per call is written thousands of times per hour and says + * nothing new. The caller logs state *changes* instead. + */ +async function readSessionEntries(projectDir: string, sessionId: string): Promise { const expectedSessionFile = join(projectDir, `${sessionId}.jsonl`); - logger.debug(`[SESSION_SCANNER] Reading session file: ${expectedSessionFile}`); let file: string; try { file = await readFile(expectedSessionFile, 'utf-8'); } catch (error) { - logger.debug(`[SESSION_SCANNER] Session file not found: ${expectedSessionFile}`); - return []; + return null; } let lines = file.split('\n'); let entries: SessionLogEntry[] = []; diff --git a/packages/happy-cli/src/ui/logger.ts b/packages/happy-cli/src/ui/logger.ts index 48d413b1ba..b1c3dc318c 100644 --- a/packages/happy-cli/src/ui/logger.ts +++ b/packages/happy-cli/src/ui/logger.ts @@ -46,6 +46,29 @@ function getSessionLogPath(): string { return join(configuration.logsDir, filename) } +/** + * Hard cap on how much a single inspected argument may contribute to a log line. + * + * `inspect(depth: 5)` on any value that transitively holds a Node socket — every + * error thrown by a failed HTTP or socket.io call does — expands into hundreds of + * lines of TLS cipher lists and internal symbols. Measured on one machine's + * ~1.3 GB of session logs, those dumps were 55% of all bytes written, and the + * part anyone actually reads (the error's own top-level fields) fits far inside + * this cap. Truncating keeps the readable head and drops the rest. + */ +const MAX_INSPECTED_ARG_CHARS = 4000 + +function inspectForLog(arg: unknown): string { + if (typeof arg === 'string') { + return arg + } + const text = inspect(arg, { depth: 5, breakLength: 120 }) + if (text.length <= MAX_INSPECTED_ARG_CHARS) { + return text + } + return `${text.slice(0, MAX_INSPECTED_ARG_CHARS)}… [truncated ${text.length - MAX_INSPECTED_ARG_CHARS} more chars]` +} + class Logger { private dangerouslyUnencryptedServerLoggingUrl: string | undefined @@ -202,9 +225,7 @@ class Logger { } private logToFile(prefix: string, message: string, ...args: unknown[]): void { - const logLine = `${prefix} ${message} ${args.map(arg => - typeof arg === 'string' ? arg : inspect(arg, { depth: 5, breakLength: 120 }) - ).join(' ')}\n` + const logLine = `${prefix} ${message} ${args.map(inspectForLog).join(' ')}\n` // Send to remote server if configured if (this.dangerouslyUnencryptedServerLoggingUrl) {