Skip to content
31 changes: 31 additions & 0 deletions src/utils/__tests__/block-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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');
Expand Down
160 changes: 160 additions & 0 deletions src/utils/__tests__/jsonl-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -29,6 +33,48 @@ 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: '<synthetic>',
stop_reason: 'stop_sequence',
usage: {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0
}
}
});
}

// 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;
Expand Down Expand Up @@ -71,6 +117,120 @@ describe('jsonl block metrics integration', () => {
expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString());
});

// 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');
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 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('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(), message: { usage: { output_tokens: 50 } } }),
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);
// 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 });
Expand Down
81 changes: 50 additions & 31 deletions src/utils/jsonl-blocks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as fs from 'node:fs';
import path from 'node:path';
import { globSync } from 'tinyglobby';

import type { BlockMetrics } from '../types';
Expand Down Expand Up @@ -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
});
Expand Down Expand Up @@ -104,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];
Expand All @@ -115,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;
}

Expand All @@ -129,7 +132,7 @@ function findMostRecentBlockStartTime(
}

// If this was our last chunk, use what we have
if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) {
if (isLastChunk) {
break;
}
}
Expand Down Expand Up @@ -177,41 +180,27 @@ 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
*/
function getAllTimestampsFromFile(filePath: string): Date[] {
const timestamps: Date[] = [];
try {
for (const line of iterateJsonlLinesSync(filePath)) {
const json = parseJsonlLine(line) as {
timestamp?: string;
isSidechain?: 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;

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);
}

Expand All @@ -221,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)
*/
Expand Down
9 changes: 6 additions & 3 deletions src/utils/jsonl-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}

Expand Down
Loading