diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index d81329ef8fd8..4db4af0fed3f 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -1579,7 +1579,7 @@ describe("Claude session catalog", () => { } }); - it("reuses cached metadata for unchanged discovered transcripts", async () => { + it("reuses the assembled scan within the TTL without per-file filesystem work", async () => { const home = await createHome(); const sessionIds = ["cached-session-a", "cached-session-b"]; await writeProject({ @@ -1589,18 +1589,32 @@ describe("Claude session catalog", () => { sessionIds.map((sessionId) => [sessionId, [sdkCliMessage(sessionId, sessionId)]]), ), }); + const realpathSpy = vi.spyOn(fs, "realpath"); + const statSpy = vi.spyOn(fs, "stat"); const openSpy = vi.spyOn(fs, "open"); + const readFileSpy = vi.spyOn(fs, "readFile"); const first = await listLocalClaudeSessionPage({}, home); expect(openSpy).toHaveBeenCalledTimes(2); + realpathSpy.mockClear(); + statSpy.mockClear(); openSpy.mockClear(); + readFileSpy.mockClear(); const second = await listLocalClaudeSessionPage({}, home); expect(second).toEqual(first); + const isCatalogFile = (value: unknown) => + typeof value === "string" && + (value.endsWith(".jsonl") || + value.endsWith("sessions-index.json") || + path.basename(value).startsWith("local_")); + expect(realpathSpy.mock.calls.filter(([filePath]) => isCatalogFile(filePath))).toEqual([]); + expect(statSpy.mock.calls.filter(([filePath]) => isCatalogFile(filePath))).toEqual([]); expect(openSpy).not.toHaveBeenCalled(); + expect(readFileSpy.mock.calls.filter(([filePath]) => isCatalogFile(filePath))).toEqual([]); }); - it("rescans only a changed transcript and refreshes a negative result", async () => { + it("invalidates the assembled scan on a project directory mtime change", async () => { const home = await createHome(); const projectDir = path.join(home, ".claude", "projects", "-workspace"); const changedPath = path.join(projectDir, "changed-session.jsonl"); @@ -1621,6 +1635,7 @@ describe("Claude session catalog", () => { ); const changedTime = new Date(Date.now() + 2_000); await fs.utimes(changedPath, changedTime, changedTime); + await fs.utimes(projectDir, changedTime, changedTime); const resolvedChangedPath = await fs.realpath(changedPath); const resolvedUnchangedPath = await fs.realpath(unchangedPath); openSpy.mockClear(); @@ -1640,14 +1655,17 @@ describe("Claude session catalog", () => { const home = await createHome(); const projectDir = path.join(home, ".claude", "projects", "-workspace"); const newPath = path.join(projectDir, "new-session.jsonl"); + const fixedDirectoryTime = new Date("2026-07-20T12:00:00.000Z"); await writeProject({ home, entries: [], transcripts: { "existing-session": [sdkCliMessage("existing-session", "Existing")] }, }); + await fs.utimes(projectDir, fixedDirectoryTime, fixedDirectoryTime); const openSpy = vi.spyOn(fs, "open"); await listLocalClaudeSessionPage({}, home); await fs.writeFile(newPath, `${JSON.stringify(sdkCliMessage("new-session", "New"))}\n`); + await fs.utimes(projectDir, fixedDirectoryTime, fixedDirectoryTime); const resolvedNewPath = await fs.realpath(newPath); openSpy.mockClear(); @@ -1659,6 +1677,160 @@ describe("Claude session catalog", () => { expect(openSpy.mock.calls.map(([filePath]) => filePath)).toEqual([resolvedNewPath]); }); + it("refreshes a warm catalog when a specific new transcript is requested", async () => { + const home = await createHome(); + const projectDir = path.join(home, ".claude", "projects", "-workspace"); + const sessionId = "just-created-session"; + const fixedDirectoryTime = new Date("2026-07-20T12:00:00.000Z"); + await writeProject({ + home, + entries: [], + transcripts: { "existing-session": [sdkCliMessage("existing-session", "Existing")] }, + }); + await fs.utimes(projectDir, fixedDirectoryTime, fixedDirectoryTime); + await listLocalClaudeSessionPage({}, home); + await fs.writeFile( + path.join(projectDir, `${sessionId}.jsonl`), + `${JSON.stringify(sdkCliMessage(sessionId, "New transcript"))}\n`, + ); + // Keep the directory fingerprint unchanged so this exercises the per-id miss refresh rather + // than the normal catalog invalidation path. + await fs.utimes(projectDir, fixedDirectoryTime, fixedDirectoryTime); + + await expect( + readLocalClaudeTranscriptPage({ threadId: sessionId, limit: 1 }, home), + ).resolves.toEqual( + expect.objectContaining({ + items: [expect.objectContaining({ text: "New transcript" })], + }), + ); + }); + + it("keys index and Desktop metadata parse caches by path, mtime, and size", async () => { + const home = await createHome(); + const projectDir = path.join(home, ".claude", "projects", "-workspace"); + const indexPath = path.join(projectDir, "sessions-index.json"); + const desktopPath = path.join( + home, + "Library", + "Application Support", + "Claude", + "claude-code-sessions", + "account", + "workspace", + "local_metadata-cache.json", + ); + const indexedPath = path.join(projectDir, "indexed-session.jsonl"); + const desktopTranscriptPath = path.join(projectDir, "desktop-session.jsonl"); + const entries = [ + { + sessionId: "indexed-session", + fullPath: indexedPath, + summary: "Indexed before", + isSidechain: false, + }, + { + sessionId: "desktop-session", + fullPath: desktopTranscriptPath, + summary: "Desktop index", + isSidechain: false, + }, + ]; + await writeProject({ + home, + entries, + transcripts: { + "indexed-session": [message("indexed-session", "user", "Indexed", 1)], + "desktop-session": [message("desktop-session", "user", "Desktop", 1)], + }, + }); + await writeDesktopMetadata(home, "metadata-cache", { + cliSessionId: "desktop-session", + title: "Desktop before", + }); + const readFileSpy = vi.spyOn(fs, "readFile"); + const metadataReads = () => + readFileSpy.mock.calls + .map(([filePath]) => filePath) + .filter((filePath) => filePath === indexPath || filePath === desktopPath); + + await listLocalClaudeSessionPage({}, home); + expect(metadataReads()).toEqual(expect.arrayContaining([indexPath, desktopPath])); + const firstRefreshTime = new Date(Date.now() + 2_000); + await fs.utimes(projectDir, firstRefreshTime, firstRefreshTime); + readFileSpy.mockClear(); + + await listLocalClaudeSessionPage({}, home); + expect(metadataReads()).toEqual([]); + + await fs.writeFile( + indexPath, + JSON.stringify({ + version: 1, + entries: [{ ...entries[0], summary: "Indexed after a longer title" }, entries[1]], + }), + ); + await fs.writeFile( + desktopPath, + JSON.stringify({ + cliSessionId: "desktop-session", + title: "Desktop after a longer title", + }), + ); + const secondRefreshTime = new Date(Date.now() + 4_000); + await Promise.all([ + fs.utimes(indexPath, secondRefreshTime, secondRefreshTime), + fs.utimes(desktopPath, secondRefreshTime, secondRefreshTime), + fs.utimes(projectDir, secondRefreshTime, secondRefreshTime), + ]); + readFileSpy.mockClear(); + + const refreshed = await listLocalClaudeSessionPage({}, home); + expect(metadataReads()).toEqual(expect.arrayContaining([indexPath, desktopPath])); + expect( + Object.fromEntries(refreshed.sessions.map((record) => [record.threadId, record.name])), + ).toEqual({ + "desktop-session": "Desktop after a longer title", + "indexed-session": "Indexed after a longer title", + }); + }); + + it("retries transient index reads without waiting for the file metadata to change", async () => { + const home = await createHome(); + const projectDir = path.join(home, ".claude", "projects", "-workspace"); + const indexPath = path.join(projectDir, "sessions-index.json"); + const sessionId = "retry-index-session"; + await writeProject({ + home, + entries: [ + { + sessionId, + fullPath: path.join(projectDir, `${sessionId}.jsonl`), + summary: "Recovered index", + isSidechain: false, + }, + ], + transcripts: { [sessionId]: [message(sessionId, "user", "Indexed only", 1)] }, + }); + const readFile = fs.readFile.bind(fs); + let failIndexRead = true; + vi.spyOn(fs, "readFile").mockImplementation(async (...args) => { + if (failIndexRead && args[0] === indexPath) { + failIndexRead = false; + throw new Error("transient index read failure"); + } + return await readFile(...args); + }); + + expect((await listLocalClaudeSessionPage({}, home)).sessions).toEqual([]); + const refreshTime = new Date(Date.now() + 2_000); + await fs.utimes(projectDir, refreshTime, refreshTime); + + await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({ + sessions: [{ threadId: sessionId, name: "Recovered index" }], + }); + }); + it("evicts a deleted transcript after a complete scan", async () => { const home = await createHome(); const projectDir = path.join(home, ".claude", "projects", "-workspace"); @@ -1671,13 +1843,16 @@ describe("Claude session catalog", () => { transcripts: { [sessionId]: [sdkCliMessage(sessionId, "Alpha")] }, }); await fs.utimes(transcriptPath, fixedTime, fixedTime); + await fs.utimes(projectDir, fixedTime, fixedTime); const originalStat = await fs.stat(transcriptPath); await listLocalClaudeSessionPage({}, home); await fs.rm(transcriptPath); + await fs.utimes(projectDir, fixedTime, fixedTime); expect((await listLocalClaudeSessionPage({}, home)).sessions).toEqual([]); await fs.writeFile(transcriptPath, `${JSON.stringify(sdkCliMessage(sessionId, "Bravo"))}\n`); await fs.utimes(transcriptPath, fixedTime, fixedTime); + await fs.utimes(projectDir, fixedTime, fixedTime); const recreatedStat = await fs.stat(transcriptPath); expect({ mtimeMs: recreatedStat.mtimeMs, size: recreatedStat.size }).toEqual({ mtimeMs: originalStat.mtimeMs, diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index 5d58c0c78f14..190ea7c80581 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -1,3 +1,4 @@ +import type { Dirent, Stats } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -62,6 +63,9 @@ const MAX_SEARCH_LENGTH = 500; const MAX_SESSION_PULL_REQUESTS = 20; const MAX_CATALOG_DISCOVERY_FILES = 10_000; const MAX_CATALOG_DISCOVERY_CACHE_ENTRIES = 20_000; +const MAX_CATALOG_JSON_CACHE_ENTRIES = 4_000; +const MAX_CLAUDE_SESSION_SCAN_CACHE_ENTRIES = 8; +const CLAUDE_SESSION_SCAN_TTL_MS = 5_000; const CLAUDE_METADATA_PREFIX_BYTES = 1024 * 1024; const CLAUDE_METADATA_READ_CHUNK_BYTES = 16 * 1024; const MAX_CATALOG_METADATA_SCAN_BYTES = 64 * 1024 * 1024; @@ -137,20 +141,38 @@ type CatalogDiscoveryCacheEntry = { sidechain: boolean; }; -const catalogDiscoveryCache = new Map(); +type CatalogJsonCacheEntry = { + mtimeMs: number; + size: number; + value: unknown; +}; -function cacheCatalogDiscovery(filePath: string, entry: CatalogDiscoveryCacheEntry): void { - catalogDiscoveryCache.delete(filePath); - catalogDiscoveryCache.set(filePath, entry); - while (catalogDiscoveryCache.size > MAX_CATALOG_DISCOVERY_CACHE_ENTRIES) { - const oldestPath = catalogDiscoveryCache.keys().next().value; - if (oldestPath === undefined) { +type ClaudeSessionScanCacheEntry = { + treeStamp: string; + expiresAt: number; + records: Promise; +}; + +const catalogDiscoveryCache = new Map(); +const catalogJsonCache = new Map(); +const claudeSessionScanCache = new Map(); + +function setBoundedCache(cache: Map, key: K, value: V, maxEntries: number): void { + cache.delete(key); + cache.set(key, value); + while (cache.size > maxEntries) { + const oldest = cache.keys().next(); + if (oldest.done) { break; } - catalogDiscoveryCache.delete(oldestPath); + cache.delete(oldest.value); } } +function cacheCatalogDiscovery(filePath: string, entry: CatalogDiscoveryCacheEntry): void { + setBoundedCache(catalogDiscoveryCache, filePath, entry, MAX_CATALOG_DISCOVERY_CACHE_ENTRIES); +} + function optionalString(value: unknown, maxLength = MAX_STRING_LENGTH): string | undefined { if (typeof value !== "string") { return undefined; @@ -273,7 +295,7 @@ async function safeSessionFile( resolvedRoot: string, candidate: string, sessionId: string, -): Promise { +): Promise<{ filePath: string; stat: Stats } | undefined> { if (!isWithin(root, candidate) || path.basename(candidate) !== `${sessionId}.jsonl`) { return undefined; } @@ -283,15 +305,32 @@ async function safeSessionFile( return undefined; } const stat = await fs.stat(resolvedCandidate); - return stat.isFile() ? resolvedCandidate : undefined; + return stat.isFile() ? { filePath: resolvedCandidate, stat } : undefined; } catch { return undefined; } } async function readJsonFile(filePath: string): Promise { + const stat = await fs.stat(filePath).catch(() => undefined); + if (!stat?.isFile()) { + catalogJsonCache.delete(filePath); + return undefined; + } + const cached = catalogJsonCache.get(filePath); + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + setBoundedCache(catalogJsonCache, filePath, cached, MAX_CATALOG_JSON_CACHE_ENTRIES); + return cached.value; + } try { - return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown; + const value = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown; + setBoundedCache( + catalogJsonCache, + filePath, + { mtimeMs: stat.mtimeMs, size: stat.size, value }, + MAX_CATALOG_JSON_CACHE_ENTRIES, + ); + return value; } catch { return undefined; } @@ -311,6 +350,34 @@ function projectsDir(homeDir: string): string { return path.join(homeDir, ".claude", "projects"); } +async function readProjectsTreeStamp(root: string): Promise { + let entries: Dirent[]; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch { + return "unavailable"; + } + const directoryNames = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .toSorted(); + const directorySnapshots = await Promise.all( + directoryNames.map(async (name) => { + const directory = path.join(root, name); + const [stat, childNames] = await Promise.all([ + fs.stat(directory).catch(() => undefined), + fs.readdir(directory).catch(() => undefined), + ]); + return [ + name, + stat?.isDirectory() === true ? stat.mtimeMs : null, + childNames?.toSorted() ?? null, + ] as const; + }), + ); + return JSON.stringify(directorySnapshots); +} + function desktopSessionsDir(homeDir: string): string { return path.join(homeDir, "Library", "Application Support", "Claude", "claude-code-sessions"); } @@ -394,13 +461,13 @@ async function readIndexRecords(homeDir: string): Promise<{ continue; } const indexedPath = optionalString(entry.fullPath, MAX_STRING_LENGTH); - const filePath = await safeSessionFile( + const safeFile = await safeSessionFile( root, resolvedRoot, indexedPath ?? path.join(projectDir, `${sessionId}.jsonl`), sessionId, ); - if (!filePath) { + if (!safeFile) { continue; } const createdAt = timestampMs(entry.created); @@ -420,7 +487,7 @@ async function readIndexRecords(homeDir: string): Promise<{ ? { gitBranch: optionalString(entry.gitBranch, 500) } : {}), archived: false, - filePath, + filePath: safeFile.filePath, }); } } @@ -435,9 +502,9 @@ async function locateSessionFile(homeDir: string, sessionId: string): Promise undefined); - if (!fileStat?.isFile()) { - continue; - } const cached = catalogDiscoveryCache.get(filePath); // Claude transcripts only append while active, then stay static, so mtime+size+ino identify // the parsed content (ino also rejects an atomic replacement that reused the same mtime/size), @@ -671,7 +735,7 @@ async function discoverCliRecords( } } -async function listClaudeSessions(homeDir = currentHomeDir()): Promise { +async function scanClaudeSessions(homeDir: string): Promise { const [indexed, desktop] = await Promise.all([ readIndexRecords(homeDir), readDesktopMetadata(homeDir), @@ -718,6 +782,42 @@ async function listClaudeSessions(homeDir = currentHomeDir()): Promise { + const root = projectsDir(homeDir); + const treeStamp = await readProjectsTreeStamp(root); + const cached = claudeSessionScanCache.get(root); + // This tree stamp is the catalog cache's cheap freshness owner: exact child membership makes + // same-tick adds, deletes, and renames visible even when a filesystem reuses the directory mtime. + // File appends and Desktop-only changes may stay stale for at most the five-second TTL. + if ( + options.forceRefresh !== true && + cached && + cached.treeStamp === treeStamp && + cached.expiresAt > Date.now() + ) { + setBoundedCache(claudeSessionScanCache, root, cached, MAX_CLAUDE_SESSION_SCAN_CACHE_ENTRIES); + return await cached.records; + } + const records = scanClaudeSessions(homeDir); + const entry = { + treeStamp, + expiresAt: Date.now() + CLAUDE_SESSION_SCAN_TTL_MS, + records, + }; + setBoundedCache(claudeSessionScanCache, root, entry, MAX_CLAUDE_SESSION_SCAN_CACHE_ENTRIES); + try { + return await records; + } catch (error) { + if (claudeSessionScanCache.get(root) === entry) { + claudeSessionScanCache.delete(root); + } + throw error; + } +} + function encodeOffset(offset: number): string { return Buffer.from(JSON.stringify({ offset }), "utf8").toString("base64url"); } @@ -852,9 +952,16 @@ export async function readLocalClaudeTranscriptPage( homeDir = currentHomeDir(), ): Promise> { const params = readTranscriptParams(value); - const filePath = (await listClaudeSessions(homeDir)).find( + let filePath = (await listClaudeSessions(homeDir)).find( (record) => record.threadId === params.threadId, )?.filePath; + if (!filePath) { + // A just-created session can predate the short catalog TTL. Specific reads must retry against + // disk so opening a new thread never fails only because the sidebar snapshot is still warm. + filePath = (await listClaudeSessions(homeDir, { forceRefresh: true })).find( + (record) => record.threadId === params.threadId, + )?.filePath; + } if (!filePath) { throw new ClaudeCatalogParamsError("Claude session is unavailable"); }