From 39022c5788b2cfeace4454fdfc25211fffde7679 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 00:38:10 -0500 Subject: [PATCH 1/8] fix(blocks): find transcripts when the config dir name has glob characters The transcript fallback for Block Timer, Block Reset Timer and Block % globbed `/projects/**/*.jsonl` with the absolute config directory written into the pattern. Characters a directory name can contain, such as ( ) [ ] { } * ?, are glob syntax inside a pattern, so with CLAUDE_CONFIG_DIR at a path like "~/claude (work)/.claude" the pattern never matched the literal path. Every scan found no transcripts, so Block Timer stayed at "0hr 0m" and Block Reset Timer at [Loading] for as long as that directory was in use. The glob already runs with the config directory as its cwd, so the pattern is now the relative `projects/**/*.jsonl`. The directory is then only ever a path, never part of the pattern. --- src/utils/__tests__/jsonl-blocks.test.ts | 20 ++++++++++++++++++++ src/utils/jsonl-blocks.ts | 7 +++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/utils/__tests__/jsonl-blocks.test.ts b/src/utils/__tests__/jsonl-blocks.test.ts index 8193f330a..806e71283 100644 --- a/src/utils/__tests__/jsonl-blocks.test.ts +++ b/src/utils/__tests__/jsonl-blocks.test.ts @@ -71,6 +71,26 @@ describe('jsonl block metrics integration', () => { expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString()); }); + it.each(['claude (work)', 'cfg[1]', 'brace{a}', 'star*x', 'q?mark'])('finds transcripts under a config directory named %j', (dirName) => { + const configDir = path.join(tempClaudeDir, dirName); + process.env.CLAUDE_CONFIG_DIR = configDir; + const projectsDir = path.join(configDir, 'projects', 'project-a'); + fs.mkdirSync(projectsDir, { recursive: true }); + + const now = new Date(); + const blockStartSource = new Date(now.getTime() - (30 * 60 * 1000)); + const recentActivity = new Date(now.getTime() - (10 * 60 * 1000)); + + fs.writeFileSync(path.join(projectsDir, 'session.jsonl'), [ + makeUsageLine(blockStartSource), + makeUsageLine(recentActivity) + ].join('\n')); + + const metrics = getBlockMetrics(); + + expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(blockStartSource).toISOString()); + }); + it('returns null when the most recent activity is older than the session window', () => { const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a'); fs.mkdirSync(projectsDir, { recursive: true }); diff --git a/src/utils/jsonl-blocks.ts b/src/utils/jsonl-blocks.ts index 703319aa6..d39bb4635 100644 --- a/src/utils/jsonl-blocks.ts +++ b/src/utils/jsonl-blocks.ts @@ -1,5 +1,4 @@ import * as fs from 'node:fs'; -import path from 'node:path'; import { globSync } from 'tinyglobby'; import type { BlockMetrics } from '../types'; @@ -40,9 +39,9 @@ function findMostRecentBlockStartTime( const now = new Date(); // Step 1: Find all JSONL files with their modification times - // Use forward slashes for glob patterns on all platforms (tinyglobby requirement) - const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl'); - const files = globSync([pattern], { + // Keep the pattern relative to cwd: inside a pattern, characters a directory + // name can contain, such as ( ) [ ] { } * ?, are glob syntax + const files = globSync(['projects/**/*.jsonl'], { absolute: true, // Ensure we get absolute paths cwd: rootDir // Set working directory to rootDir }); From 764923a1fd5838e335bf7bf7b5b6548e43ef78fb Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 00:39:18 -0500 Subject: [PATCH 2/8] fix(blocks): don't count rejected requests as block activity When a request is rejected (a usage limit, an expired login, an API outage), Claude Code writes an assistant row with isApiErrorMessage set, model "" and every usage field 0. The block scan only checked that input_tokens and output_tokens were numbers, so these rows passed as activity even though nothing was served: - With only rejected requests in the last 5 hours, Block Timer and Block Reset Timer showed a running block that doesn't exist, e.g. "Block: 3hr 1m" / "Reset: 1hr 58m" instead of "0hr 0m" / [Loading]. - When rejected requests came before real work, the block started at the hour of the first rejection, so the reset was reported hours early. Either wrong start was then cached for 5 hours. The scan now skips rows flagged isApiErrorMessage, as the token and context metrics and Cache Timer already do. --- src/utils/__tests__/jsonl-blocks.test.ts | 59 ++++++++++++++++++++++++ src/utils/jsonl-blocks.ts | 6 +++ 2 files changed, 65 insertions(+) diff --git a/src/utils/__tests__/jsonl-blocks.test.ts b/src/utils/__tests__/jsonl-blocks.test.ts index 806e71283..62e4c6d65 100644 --- a/src/utils/__tests__/jsonl-blocks.test.ts +++ b/src/utils/__tests__/jsonl-blocks.test.ts @@ -29,6 +29,26 @@ function makeUsageLine(timestamp: Date): string { }); } +// The row Claude Code writes when a request is rejected (a usage limit, an +// expired login, an outage): its usage is all zeros +function makeApiErrorLine(timestamp: Date): string { + return JSON.stringify({ + type: 'assistant', + timestamp: timestamp.toISOString(), + isApiErrorMessage: true, + message: { + model: '', + stop_reason: 'stop_sequence', + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0 + } + } + }); +} + describe('jsonl block metrics integration', () => { let tempClaudeDir: string; let originalClaudeConfigDir: string | undefined; @@ -91,6 +111,45 @@ describe('jsonl block metrics integration', () => { expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(blockStartSource).toISOString()); }); + it('returns null when the only recent rows are rejected requests', () => { + const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a'); + fs.mkdirSync(projectsDir, { recursive: true }); + const transcriptPath = path.join(projectsDir, 'session.jsonl'); + + const now = new Date(); + fs.writeFileSync(transcriptPath, [ + makeApiErrorLine(new Date(now.getTime() - (170 * 60 * 1000))), + makeApiErrorLine(new Date(now.getTime() - (120 * 60 * 1000))), + makeApiErrorLine(new Date(now.getTime() - (45 * 60 * 1000))) + ].join('\n')); + + const metrics = getBlockMetrics(); + + expect(metrics).toBeNull(); + }); + + it('starts the block at the first real request after rejected ones', () => { + const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a'); + fs.mkdirSync(projectsDir, { recursive: true }); + const transcriptPath = path.join(projectsDir, 'session.jsonl'); + + const now = new Date(); + const firstRealRequest = new Date(now.getTime() - (45 * 60 * 1000)); + const recentActivity = new Date(now.getTime() - (10 * 60 * 1000)); + + fs.writeFileSync(transcriptPath, [ + makeApiErrorLine(new Date(now.getTime() - (170 * 60 * 1000))), + makeApiErrorLine(new Date(now.getTime() - (160 * 60 * 1000))), + makeUsageLine(firstRealRequest), + makeUsageLine(recentActivity) + ].join('\n')); + + const metrics = getBlockMetrics(); + + expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(firstRealRequest).toISOString()); + expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString()); + }); + it('returns null when the most recent activity is older than the session window', () => { const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a'); fs.mkdirSync(projectsDir, { recursive: true }); diff --git a/src/utils/jsonl-blocks.ts b/src/utils/jsonl-blocks.ts index d39bb4635..ca0f86f4c 100644 --- a/src/utils/jsonl-blocks.ts +++ b/src/utils/jsonl-blocks.ts @@ -186,6 +186,7 @@ function getAllTimestampsFromFile(filePath: string): Date[] { const json = parseJsonlLine(line) as { timestamp?: string; isSidechain?: boolean; + isApiErrorMessage?: boolean; message?: { usage?: { input_tokens?: number; output_tokens?: number } }; } | null; if (!json) { @@ -205,6 +206,11 @@ function getAllTimestampsFromFile(filePath: string): Date[] { if (json.isSidechain === true) continue; + // Claude Code logs a rejected request (a usage limit, an expired + // login, an outage) as a row with all-zero usage; nothing was served + if (json.isApiErrorMessage === true) + continue; + const timestamp = json.timestamp; if (typeof timestamp !== 'string') continue; From 47e61debfb8b5171657b77e516d4dbf400468e32 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 00:41:19 -0500 Subject: [PATCH 3/8] fix(blocks): don't end the block chain at a gap a skipped transcript may fill To find the current 5-hour block, the transcript fallback walks back from the newest activity to the first gap of 5 hours or more, then chains blocks forward from there. It reads in widening passes (10h, 20h, 48h), and each pass reads only the transcripts last written after its cutoff. But it keeps every row in the transcripts it reads, so a session left open since yesterday brings in rows from long before the cutoff. A gap between those rows and today's was taken as the session boundary even when another transcript, skipped only because it was last written before the cutoff, has rows inside it. The wider passes never ran, the chain started at the wrong row, and the block start came out hours early: Block Timer read 4hr 57m instead of 2hr 57m, the reset was predicted 2 hours early, and the wrong start was cached until then. A skipped transcript can only hold rows from before the cutoff. So a gap is now a boundary only if the part of it after the cutoff spans a session by itself; otherwise the next, wider pass reads the skipped transcripts first. The last pass still takes any gap, as before. --- src/utils/__tests__/jsonl-blocks.test.ts | 50 ++++++++++++++++++++++++ src/utils/jsonl-blocks.ts | 10 +++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/jsonl-blocks.test.ts b/src/utils/__tests__/jsonl-blocks.test.ts index 62e4c6d65..eb04b8b23 100644 --- a/src/utils/__tests__/jsonl-blocks.test.ts +++ b/src/utils/__tests__/jsonl-blocks.test.ts @@ -17,6 +17,10 @@ function floorToHourUtc(timestamp: Date): Date { return floored; } +function hoursAgo(now: Date, hours: number): Date { + return new Date(now.getTime() - (hours * 60 * 60 * 1000)); +} + function makeUsageLine(timestamp: Date): string { return JSON.stringify({ timestamp: timestamp.toISOString(), @@ -49,6 +53,28 @@ function makeApiErrorLine(timestamp: Date): string { }); } +// A session left open since yesterday: one row 30h ago, then one every 30 +// minutes from 9h ago, so its own rows show a 21h gap +function writeResumedSession(claudeDir: string, now: Date): void { + const projectDir = path.join(claudeDir, 'projects', 'project-a'); + fs.mkdirSync(projectDir, { recursive: true }); + const rows = [hoursAgo(now, 30)]; + for (let hours = 9; hours > 0; hours -= 0.5) { + rows.push(hoursAgo(now, hours)); + } + fs.writeFileSync(path.join(projectDir, 'session.jsonl'), rows.map(row => makeUsageLine(row)).join('\n')); +} + +// Another project's session with rows the given hours ago, last written at the newest +function writeOtherProjectSession(claudeDir: string, now: Date, rowHoursAgo: number[]): void { + const projectDir = path.join(claudeDir, 'projects', 'project-b'); + fs.mkdirSync(projectDir, { recursive: true }); + const transcriptPath = path.join(projectDir, 'session.jsonl'); + fs.writeFileSync(transcriptPath, rowHoursAgo.map(hours => makeUsageLine(hoursAgo(now, hours))).join('\n')); + const lastWritten = hoursAgo(now, Math.min(...rowHoursAgo)); + fs.utimesSync(transcriptPath, lastWritten, lastWritten); +} + describe('jsonl block metrics integration', () => { let tempClaudeDir: string; let originalClaudeConfigDir: string | undefined; @@ -150,6 +176,30 @@ describe('jsonl block metrics integration', () => { expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString()); }); + it('reads further back when a gap reaches past the lookback, since an older transcript may fill it', () => { + const now = new Date(); + writeResumedSession(tempClaudeDir, now); + // Last written 10.5h ago, just outside the first lookback, and fills the gap + writeOtherProjectSession(tempClaudeDir, now, [12, 11.5, 11, 10.5]); + + const metrics = getBlockMetrics(); + + // One chain from 12h ago, through blocks starting about 7h and 2h ago + expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(hoursAgo(now, 2)).toISOString()); + }); + + it('still ends the block chain at a gap that no older transcript fills', () => { + const now = new Date(); + writeResumedSession(tempClaudeDir, now); + // Inside the gap, but more than 5h before the session resumed + writeOtherProjectSession(tempClaudeDir, now, [27, 26]); + + const metrics = getBlockMetrics(); + + // One chain from 9h ago, through a block starting about 4h ago + expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(hoursAgo(now, 4)).toISOString()); + }); + it('returns null when the most recent activity is older than the session window', () => { const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a'); fs.mkdirSync(projectsDir, { recursive: true }); diff --git a/src/utils/jsonl-blocks.ts b/src/utils/jsonl-blocks.ts index ca0f86f4c..9773303c8 100644 --- a/src/utils/jsonl-blocks.ts +++ b/src/utils/jsonl-blocks.ts @@ -103,6 +103,7 @@ function findMostRecentBlockStartTime( } // Look for a session gap in this chunk + const isLastChunk = lookbackHours === lookbackChunks[lookbackChunks.length - 1]; continuousWorkStart = mostRecentTimestamp; for (let i = 1; i < timestamps.length; i++) { const currentTimestamp = timestamps[i]; @@ -114,8 +115,11 @@ function findMostRecentBlockStartTime( const gap = previousTimestamp.getTime() - currentTimestamp.getTime(); if (gap >= sessionDurationMs) { - // Found a true session boundary - foundSessionGap = true; + // Transcripts last written before the cutoff weren't read, and + // their rows could fill the part of this gap before it. Unless the + // part after the cutoff spans a session by itself, look further back + foundSessionGap = isLastChunk + || previousTimestamp.getTime() - cutoffTime.getTime() >= sessionDurationMs; break; } @@ -128,7 +132,7 @@ function findMostRecentBlockStartTime( } // If this was our last chunk, use what we have - if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) { + if (isLastChunk) { break; } } From bf6ad9290b78ffa2411588c9c49be2997e9eb0c2 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 00:42:52 -0500 Subject: [PATCH 4/8] fix(blocks): don't trust block cache entries dated ahead of the clock The block cache keeps either the current block's start or, after a scan that found no block, an instant until which scans are skipped (one minute ahead). Neither had an upper bound. When the system clock runs ahead for a render (a dual-boot clock mismatch, a manual change) and is then set back: - a "no block" entry stood for the whole skew plus a minute, so Block Timer showed "0hr 0m" and Block Reset Timer [Loading] for that long (a day, after a day's skew), even with fresh activity in the transcripts; - a block start cached under the fast clock lies in the future, and still counted as valid, so elapsed time stayed at 0 and Block Reset Timer at 5hr for the skew plus 5 hours. A "no block" entry is now trusted only while it ends within one interval, and a cached start only once it has begun; otherwise the transcripts are scanned again. --- src/utils/__tests__/block-cache.test.ts | 31 +++++++++++++++++++++++++ src/utils/jsonl-cache.ts | 9 ++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/block-cache.test.ts b/src/utils/__tests__/block-cache.test.ts index f57031bf3..4b3c44cd8 100644 --- a/src/utils/__tests__/block-cache.test.ts +++ b/src/utils/__tests__/block-cache.test.ts @@ -262,6 +262,21 @@ describe('getCachedBlockMetrics integration', () => { expect(result).toBeNull(); }); + it('should recalculate when the cached block starts in the future', async () => { + const { getCachedBlockMetrics, writeBlockCache } = await import('../jsonl'); + + // Written while the system clock ran ahead, before it was set back + const futureStartTime = new Date(); + futureStartTime.setHours(futureStartTime.getHours() + 2); + + writeBlockCache(futureStartTime); + + const result = getCachedBlockMetrics(); + + // Should return null because the block hasn't started and no real JSONL files exist + expect(result).toBeNull(); + }); + it('should recalculate when no cache exists', async () => { const { getCachedBlockMetrics } = await import('../jsonl'); @@ -359,6 +374,22 @@ describe('getCachedBlockMetrics negative caching', () => { expect(readRawCache().emptyUntil).not.toBe(lapsed); }); + it('scans again when a record reaches further out than one interval', async () => { + const { getCachedBlockMetrics } = await import('../jsonl'); + const cachePath = getBlockCachePath(); + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + // Written while the system clock ran ahead, before it was set back + const farAhead = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(); + fs.writeFileSync(cachePath, JSON.stringify({ + emptyUntil: farAhead, + configDir: path.resolve(process.env.CLAUDE_CONFIG_DIR ?? '') + })); + + expect(getCachedBlockMetrics()).toBeNull(); + + expect(readRawCache().emptyUntil).not.toBe(farAhead); + }); + it('ignores a record belonging to another config directory', async () => { const { getCachedBlockMetrics } = await import('../jsonl'); const otherProfile = path.join(tempDir, '.claude-other'); diff --git a/src/utils/jsonl-cache.ts b/src/utils/jsonl-cache.ts index 3a88e5556..3866ac91d 100644 --- a/src/utils/jsonl-cache.ts +++ b/src/utils/jsonl-cache.ts @@ -187,7 +187,8 @@ export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | const cachedStartTime = readBlockCache(activeConfigDir); if (cachedStartTime) { const blockEndTime = new Date(cachedStartTime.getTime() + sessionDurationMs); - if (now.getTime() <= blockEndTime.getTime()) { + // A start still ahead of now was written before the clock was set back + if (cachedStartTime.getTime() <= now.getTime() && now.getTime() <= blockEndTime.getTime()) { // Cache is valid - return cached result return { startTime: cachedStartTime, @@ -197,9 +198,11 @@ export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | // Cache expired - need to recalculate } - // A recent scan already found nothing; repeating it walks every transcript again + // A recent scan already found nothing; repeating it walks every transcript again. + // Bounded by one interval, so a record written before the clock was set back + // cannot hide a new block for longer. const emptyUntil = readEmptyBlockCache(activeConfigDir); - if (emptyUntil && now.getTime() < emptyUntil.getTime()) { + if (emptyUntil && now.getTime() < emptyUntil.getTime() && emptyUntil.getTime() <= now.getTime() + EMPTY_RESULT_TTL_MS) { return null; } From 7ee28aac9c7d6eef4b88655173a3322c3bae1cc7 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 16:32:22 -0500 Subject: [PATCH 5/8] test(blocks): cover the transcript rows the block scan skips The block scan counts only served requests as activity. Besides rejected requests, it skips lines that don't parse, rows without both input and output token counts (such as user messages), sidechain rows, and rows whose timestamp is missing, not a string, or not a valid date. Only the rejected-request case had a test against the real scan; this one puts a row of each other kind after a real request and checks that the block and its last activity come from that request alone. The row with the invalid date comes first so that, if it were kept, it would sort ahead of the real request and show up as the last activity. --- src/utils/__tests__/jsonl-blocks.test.ts | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/utils/__tests__/jsonl-blocks.test.ts b/src/utils/__tests__/jsonl-blocks.test.ts index eb04b8b23..8e1281516 100644 --- a/src/utils/__tests__/jsonl-blocks.test.ts +++ b/src/utils/__tests__/jsonl-blocks.test.ts @@ -176,6 +176,32 @@ describe('jsonl block metrics integration', () => { expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString()); }); + it('ignores rows that are not served requests or lack a valid timestamp', () => { + const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a'); + fs.mkdirSync(projectsDir, { recursive: true }); + const transcriptPath = path.join(projectsDir, 'session.jsonl'); + + const now = new Date(); + const realRequest = new Date(now.getTime() - (45 * 60 * 1000)); + const ignoredRowTime = new Date(now.getTime() - (10 * 60 * 1000)); + const usage = { input_tokens: 100, output_tokens: 50 }; + + fs.writeFileSync(transcriptPath, [ + JSON.stringify({ timestamp: 'not a date', message: { usage } }), + '{not json', + JSON.stringify({ type: 'user', timestamp: ignoredRowTime.toISOString(), message: { content: 'hello' } }), + JSON.stringify({ timestamp: ignoredRowTime.toISOString(), message: { usage: { input_tokens: 100 } } }), + JSON.stringify({ timestamp: ignoredRowTime.toISOString(), isSidechain: true, message: { usage } }), + JSON.stringify({ timestamp: ignoredRowTime.getTime(), message: { usage } }), + makeUsageLine(realRequest) + ].join('\n')); + + const metrics = getBlockMetrics(); + + expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(realRequest).toISOString()); + expect(metrics?.lastActivity.toISOString()).toBe(realRequest.toISOString()); + }); + it('reads further back when a gap reaches past the lookback, since an older transcript may fill it', () => { const now = new Date(); writeResumedSession(tempClaudeDir, now); From c54682728c4268149b74808a429da9285220f3cf Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 16:32:33 -0500 Subject: [PATCH 6/8] refactor(blocks): move the activity-row check into its own function getAllTimestampsFromFile both read the transcript and decided which rows count as block activity, so the six reasons to skip a row sat as a run of continue statements inside the line loop. getActivityTimestamp now answers that question for one row, returning its timestamp or null with early returns, and the loop only reads lines and keeps what it returns. The row shape both functions use gets a name, TranscriptRow. No change in which rows count. --- src/utils/jsonl-blocks.ts | 70 ++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/src/utils/jsonl-blocks.ts b/src/utils/jsonl-blocks.ts index 9773303c8..54e869e32 100644 --- a/src/utils/jsonl-blocks.ts +++ b/src/utils/jsonl-blocks.ts @@ -180,6 +180,13 @@ function findMostRecentBlockStartTime( return null; } +interface TranscriptRow { + timestamp?: string; + isSidechain?: boolean; + isApiErrorMessage?: boolean; + message?: { usage?: { input_tokens?: number; output_tokens?: number } }; +} + /** * Gets all timestamps from a JSONL file */ @@ -187,40 +194,13 @@ function getAllTimestampsFromFile(filePath: string): Date[] { const timestamps: Date[] = []; try { for (const line of iterateJsonlLinesSync(filePath)) { - const json = parseJsonlLine(line) as { - timestamp?: string; - isSidechain?: boolean; - isApiErrorMessage?: boolean; - message?: { usage?: { input_tokens?: number; output_tokens?: number } }; - } | null; + const json = parseJsonlLine(line) as TranscriptRow | null; if (!json) { continue; } - // Only treat entries with real token usage as block activity - const usage = json.message?.usage; - if (!usage) - continue; - - const hasInputTokens = typeof usage.input_tokens === 'number'; - const hasOutputTokens = typeof usage.output_tokens === 'number'; - if (!hasInputTokens || !hasOutputTokens) - continue; - - if (json.isSidechain === true) - continue; - - // Claude Code logs a rejected request (a usage limit, an expired - // login, an outage) as a row with all-zero usage; nothing was served - if (json.isApiErrorMessage === true) - continue; - - const timestamp = json.timestamp; - if (typeof timestamp !== 'string') - continue; - - const date = new Date(timestamp); - if (!Number.isNaN(date.getTime())) + const date = getActivityTimestamp(json); + if (date) timestamps.push(date); } @@ -230,6 +210,36 @@ function getAllTimestampsFromFile(filePath: string): Date[] { } } +/** + * Gets when a transcript row was served, or null when the row isn't block activity + */ +function getActivityTimestamp(json: TranscriptRow): Date | null { + // Only treat entries with real token usage as block activity + const usage = json.message?.usage; + if (!usage) + return null; + + const hasInputTokens = typeof usage.input_tokens === 'number'; + const hasOutputTokens = typeof usage.output_tokens === 'number'; + if (!hasInputTokens || !hasOutputTokens) + return null; + + if (json.isSidechain === true) + return null; + + // Claude Code logs a rejected request (a usage limit, an expired + // login, an outage) as a row with all-zero usage; nothing was served + if (json.isApiErrorMessage === true) + return null; + + const timestamp = json.timestamp; + if (typeof timestamp !== 'string') + return null; + + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? null : date; +} + /** * Floors a timestamp to the beginning of the hour (matching existing logic) */ From d4645f457726929c7df6a5b602646c21b95ebf71 Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Fri, 16 Oct 2026 16:44:12 -0500 Subject: [PATCH 7/8] test(blocks): add an output-only row to the skipped-rows test The block scan counts a row as activity only when it has both an input and an output token count. The skipped-rows test had a row with input tokens and no output tokens, but none with output tokens and no input tokens, so a scan that checked only the output count still passed it. This adds that row, so the test now fails if either count stops being required. --- src/utils/__tests__/jsonl-blocks.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/__tests__/jsonl-blocks.test.ts b/src/utils/__tests__/jsonl-blocks.test.ts index 8e1281516..0adeae43d 100644 --- a/src/utils/__tests__/jsonl-blocks.test.ts +++ b/src/utils/__tests__/jsonl-blocks.test.ts @@ -191,6 +191,7 @@ describe('jsonl block metrics integration', () => { '{not json', JSON.stringify({ type: 'user', timestamp: ignoredRowTime.toISOString(), message: { content: 'hello' } }), JSON.stringify({ timestamp: ignoredRowTime.toISOString(), message: { usage: { input_tokens: 100 } } }), + JSON.stringify({ timestamp: ignoredRowTime.toISOString(), message: { usage: { output_tokens: 50 } } }), JSON.stringify({ timestamp: ignoredRowTime.toISOString(), isSidechain: true, message: { usage } }), JSON.stringify({ timestamp: ignoredRowTime.getTime(), message: { usage } }), makeUsageLine(realRequest) From 8a9582a5487cf1ec775052418eba6a9264710e57 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Thu, 29 Oct 2026 10:50:07 -0400 Subject: [PATCH 8/8] test: skip invalid Windows glob directory names --- src/utils/__tests__/jsonl-blocks.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/jsonl-blocks.test.ts b/src/utils/__tests__/jsonl-blocks.test.ts index 0adeae43d..3d669da3d 100644 --- a/src/utils/__tests__/jsonl-blocks.test.ts +++ b/src/utils/__tests__/jsonl-blocks.test.ts @@ -117,7 +117,11 @@ describe('jsonl block metrics integration', () => { expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString()); }); - it.each(['claude (work)', 'cfg[1]', 'brace{a}', 'star*x', 'q?mark'])('finds transcripts under a config directory named %j', (dirName) => { + // Windows forbids * and ? in directory names. + const globDirectoryNames = ['claude (work)', 'cfg[1]', 'brace{a}', 'star*x', 'q?mark'] + .filter(dirName => process.platform !== 'win32' || !/[*?]/.test(dirName)); + + it.each(globDirectoryNames)('finds transcripts under a config directory named %j', (dirName) => { const configDir = path.join(tempClaudeDir, dirName); process.env.CLAUDE_CONFIG_DIR = configDir; const projectsDir = path.join(configDir, 'projects', 'project-a');