From 50ac83901cf42fb8df3c8a1f450eee6e71babff9 Mon Sep 17 00:00:00 2001 From: Guiraud Date: Wed, 5 Aug 2026 17:42:12 +0200 Subject: [PATCH] fix Cursor slug scan hang --- src/__tests__/cursor-parser.test.ts | 51 ++++++++++++++++ src/__tests__/cwd-from-slug.test.ts | 17 +++++- src/__tests__/index-source-cache.test.ts | 19 ++++-- src/parsers/cursor.ts | 75 ++++++++++++++++++++---- src/parsers/registry.ts | 1 + src/utils/index.ts | 18 ++++-- src/utils/slug.ts | 28 +++++---- 7 files changed, 176 insertions(+), 33 deletions(-) diff --git a/src/__tests__/cursor-parser.test.ts b/src/__tests__/cursor-parser.test.ts index c84e1c0..69025a7 100644 --- a/src/__tests__/cursor-parser.test.ts +++ b/src/__tests__/cursor-parser.test.ts @@ -1,6 +1,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { performance } from 'node:perf_hooks'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { UnifiedSession } from '../types/index.js'; @@ -462,6 +463,56 @@ describe('cursor parser hardening', () => { expect(byId.get('dddddddd-1111-2222-3333-444444444444')).toBe('/tmp/cursor-projectD-rootpath'); }); + it('reads repo.json before resolving a long project slug', async () => { + const home = makeCursorHome(); + const slug = 'continues-repo-json-a-b-c-d-e-f-g-h-i'; + const sessionId = 'eeeeeeee-1111-2222-3333-444444444444'; + writeCursorRepoJson(home, slug, { workspace: '/tmp/cursor-project-from-repo-json' }); + writeCursorTranscript(home, slug, sessionId, [ + { role: 'user', message: { content: [{ type: 'text', text: 'repo.json wins quickly' }] } }, + ]); + + const { parseCursorSessions } = await loadCursorParser(home); + const startedAt = performance.now(); + const sessions = await parseCursorSessions(); + const elapsedMs = performance.now() - startedAt; + + expect(sessions.find((session) => session.id === sessionId)?.cwd).toBe('/tmp/cursor-project-from-repo-json'); + expect(elapsedMs).toBeLessThan(100); + }); + + it('skips unrelated Cursor projects during a cwd lookup', async () => { + const home = makeCursorHome(); + const targetCwd = '/tmp/current-project'; + const unrelatedSlug = 'private-tmp-unrelated-a-b-c-d-e-f-g-h-i-j-k'; + writeCursorTranscript(home, unrelatedSlug, 'ffffffff-1111-2222-3333-444444444444', [ + { role: 'user', message: { content: [{ type: 'text', text: 'unrelated Cursor session' }] } }, + ]); + + const { parseCursorSessions } = await loadCursorParser(home); + const startedAt = performance.now(); + const sessions = await parseCursorSessions({ cwd: targetCwd }); + const elapsedMs = performance.now() - startedAt; + + expect(sessions).toEqual([]); + expect(elapsedMs).toBeLessThan(100); + }); + + it('preserves the exact cwd for a matching long slug without repo.json', async () => { + const home = makeCursorHome(); + const targetCwd = '/tmp/cursor-project-a-b-c-d-e-f-g-h-i-j'; + const slug = targetCwd.replace(/^\/+/, '').replace(/[/.]/g, '-'); + const sessionId = '99999999-1111-2222-3333-444444444444'; + writeCursorTranscript(home, slug, sessionId, [ + { role: 'user', message: { content: [{ type: 'text', text: 'exact cwd lookup' }] } }, + ]); + + const { parseCursorSessions } = await loadCursorParser(home); + const sessions = await parseCursorSessions({ cwd: targetCwd }); + + expect(sessions.find((session) => session.id === sessionId)?.cwd).toBe(targetCwd); + }); + it('discovers Cursor sub-agent transcripts under /subagents/', async () => { // Per Cursor's documented agent-transcripts layout (observed in // dev.to reverse-engineering article + VibeLens parser), sub-agent diff --git a/src/__tests__/cwd-from-slug.test.ts b/src/__tests__/cwd-from-slug.test.ts index 731e8f7..d4432f8 100644 --- a/src/__tests__/cwd-from-slug.test.ts +++ b/src/__tests__/cwd-from-slug.test.ts @@ -1,6 +1,7 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { performance } from 'node:perf_hooks'; import { describe, expect, it } from 'vitest'; import { cwdFromSlug } from '../utils/slug.js'; @@ -33,4 +34,14 @@ describe('cwdFromSlug', () => { it('keeps Unix fallback behavior for non-drive slugs', () => { expect(cwdFromSlug('Users-alice-my-project')).toBe('/Users/alice/my/project'); }); + + it('uses the bounded fallback for long Cursor slugs quickly', () => { + const slug = 'continues-regression-a-b-c-d-e-f-g-h-i-j-k'; + const startedAt = performance.now(); + const resolved = cwdFromSlug(slug); + const elapsedMs = performance.now() - startedAt; + + expect(resolved).toBe(`/${slug.replace(/-/g, '/')}`); + expect(elapsedMs).toBeLessThan(100); + }); }); diff --git a/src/__tests__/index-source-cache.test.ts b/src/__tests__/index-source-cache.test.ts index b8ba017..a61f1d3 100644 --- a/src/__tests__/index-source-cache.test.ts +++ b/src/__tests__/index-source-cache.test.ts @@ -6,6 +6,7 @@ const testState = vi.hoisted(() => ({ fakeHome: `/tmp/continues-index-source-test-${Date.now()}-${Math.random().toString(16).slice(2)}`, parseClaude: vi.fn(), parseCodex: vi.fn(), + parseCursor: vi.fn(), })); vi.mock('../utils/parser-helpers.js', () => ({ @@ -13,7 +14,7 @@ vi.mock('../utils/parser-helpers.js', () => ({ })); vi.mock('../parsers/registry.js', () => ({ - ALL_TOOLS: ['claude', 'codex'], + ALL_TOOLS: ['claude', 'codex', 'cursor'], adapters: { claude: { name: 'claude', @@ -26,6 +27,12 @@ vi.mock('../parsers/registry.js', () => ({ envVar: 'CODEX_HOME', parseSessions: testState.parseCodex, }, + cursor: { + name: 'cursor', + envVar: 'CURSOR_HOME', + parseSessions: testState.parseCursor, + supportsCwdLookup: true, + }, }, })); @@ -50,6 +57,8 @@ describe('source-scoped session index', () => { fs.rmSync(testState.fakeHome, { recursive: true, force: true }); testState.parseClaude.mockReset(); testState.parseCodex.mockReset(); + testState.parseCursor.mockReset(); + testState.parseCursor.mockResolvedValue([]); }); afterEach(() => { @@ -93,14 +102,16 @@ describe('source-scoped session index', () => { expect(testState.parseCodex).not.toHaveBeenCalled(); }); - it('stale cwd lookups rebuild the full index and include adapters without direct cwd lookup', async () => { + it('stale cwd lookups pass cwd only to adapters with direct cwd lookup', async () => { testState.parseClaude.mockResolvedValue([makeSession('claude-1', 'claude', '/tmp/project/subdir')]); testState.parseCodex.mockResolvedValue([makeSession('codex-1', 'codex', '/tmp/project')]); + testState.parseCursor.mockResolvedValue([makeSession('cursor-1', 'cursor', '/tmp/project')]); const sessions = await getSessionsByCwd('/tmp/project'); - expect(sessions.map((session) => session.id)).toEqual(['claude-1', 'codex-1']); - expect(testState.parseClaude).toHaveBeenCalledWith(); + expect(sessions.map((session) => session.id)).toEqual(['claude-1', 'codex-1', 'cursor-1']); + expect(testState.parseClaude).toHaveBeenCalledWith({ cwd: '/tmp/project' }); expect(testState.parseCodex).toHaveBeenCalledWith(); + expect(testState.parseCursor).toHaveBeenCalledWith({ cwd: '/tmp/project' }); }); }); diff --git a/src/parsers/cursor.ts b/src/parsers/cursor.ts index 93c9383..628b506 100644 --- a/src/parsers/cursor.ts +++ b/src/parsers/cursor.ts @@ -3,7 +3,13 @@ import * as path from 'node:path'; import type { VerbosityConfig } from '../config/index.js'; import { getPreset } from '../config/index.js'; import { logger } from '../logger.js'; -import type { ConversationMessage, SessionContext, SessionNotes, UnifiedSession } from '../types/index.js'; +import type { + ConversationMessage, + SessionContext, + SessionNotes, + SessionParseOptions, + UnifiedSession, +} from '../types/index.js'; import { CursorTranscriptLineSchema } from '../types/schemas.js'; import { cleanUserQueryText, isRealUserMessage, isSystemContent } from '../utils/content.js'; import { findFiles } from '../utils/fs-helpers.js'; @@ -226,7 +232,43 @@ async function readNormalizedTranscript(filePath: string): Promise { +function normalizeCwdForComparison(cwd: string): string { + const normalized = path.normalize(path.resolve(cwd)); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function cursorSlugFromCwd(cwd: string): string { + return cwd.replace(/\\/g, '/').replace(/^\/+/, '').replace(/[/.]/g, '-'); +} + +async function readRepoJsonCwd(projectDir: string): Promise { + try { + const content = await fs.promises.readFile(path.join(projectDir, 'repo.json'), 'utf8'); + const parsed = JSON.parse(content) as unknown; + if (!isRecord(parsed)) return undefined; + + for (const key of ['workspace', 'rootPath', 'path']) { + const value = getStringField(parsed, key); + if (value) return value; + } + } catch (err) { + logger.debug('cursor: failed to read project metadata while filtering by cwd', projectDir, err); + } + + return undefined; +} + +async function projectMatchesCwd(projectDir: string, targetCwd: string): Promise { + const repoCwd = await readRepoJsonCwd(projectDir); + if (repoCwd) return normalizeCwdForComparison(repoCwd) === normalizeCwdForComparison(targetCwd); + + // Projects without repo.json can still be selected safely by comparing the + // directory name with Cursor's direct slug encoding. Do not decode the slug + // here: that is the exponential operation this cwd lookup is avoiding. + return path.basename(projectDir) === cursorSlugFromCwd(targetCwd); +} + +async function findTranscriptFiles(options: SessionParseOptions = {}): Promise { if (!fs.existsSync(CURSOR_PROJECTS_DIR)) return []; const files: string[] = []; @@ -234,7 +276,10 @@ async function findTranscriptFiles(): Promise { const projectDirs = fs.readdirSync(CURSOR_PROJECTS_DIR, { withFileTypes: true }); for (const projectDir of projectDirs) { if (!projectDir.isDirectory()) continue; - const transcriptsDir = path.join(CURSOR_PROJECTS_DIR, projectDir.name, 'agent-transcripts'); + const projectPath = path.join(CURSOR_PROJECTS_DIR, projectDir.name); + if (options.cwd && !(await projectMatchesCwd(projectPath, options.cwd))) continue; + + const transcriptsDir = path.join(projectPath, 'agent-transcripts'); const found = findFiles(transcriptsDir, { match: (entry, fullPath) => entry.name.endsWith('.jsonl') && fullPath.includes('agent-transcripts'), maxDepth: 2, @@ -292,20 +337,25 @@ function getSessionId(filePath: string): string { * * Falls back to slug-derived cwd when `repo.json` is absent or unreadable. */ -async function resolveProjectCwd(projectDir: string, slug: string, cache: Map): Promise { - const fallback = cwdFromSlug(slug); - if (!projectDir) return fallback; +async function resolveProjectCwd( + projectDir: string, + slug: string, + cache: Map, + cwdFallback?: string, +): Promise { + const fallback = (): string => cwdFallback || cwdFromSlug(slug); + if (!projectDir) return fallback(); // Cache the resolved cwd per project directory: a single `repo.json` is // shared by every transcript in a project, and discovery typically iterates // many sibling sessions in the same project. const cached = cache.get(projectDir); - if (cached !== undefined) return cached || fallback; + if (cached !== undefined) return cached || fallback(); const repoJsonPath = path.join(projectDir, 'repo.json'); if (!fs.existsSync(repoJsonPath)) { cache.set(projectDir, ''); - return fallback; + return fallback(); } let resolved = ''; @@ -326,7 +376,7 @@ async function resolveProjectCwd(projectDir: string, slug: string, cache: Map { - const files = await findTranscriptFiles(); +export async function parseCursorSessions(options: SessionParseOptions = {}): Promise { + const files = await findTranscriptFiles(options); const sessionsById = new Map(); const projectCwdCache = new Map(); + const cwdFallback = options.cwd ? path.resolve(options.cwd) : undefined; for (const filePath of files) { try { @@ -406,7 +457,7 @@ export async function parseCursorSessions(): Promise { // `lines > 0 && bytes > 0` filter still excludes truly empty files. const fileStats = fs.statSync(filePath); const slug = getProjectSlug(filePath); - const cwd = await resolveProjectCwd(getProjectDir(filePath), slug, projectCwdCache); + const cwd = await resolveProjectCwd(getProjectDir(filePath), slug, projectCwdCache, cwdFallback); const summary = cleanSummary(firstUserMessage); diff --git a/src/parsers/registry.ts b/src/parsers/registry.ts index d6d8a24..cbef6fa 100644 --- a/src/parsers/registry.ts +++ b/src/parsers/registry.ts @@ -771,6 +771,7 @@ register({ binaryName: 'cursor-agent', binaryFallbacks: ['agent'], parseSessions: parseCursorSessions, + supportsCwdLookup: true, extractContext: extractCursorContext, nativeResumeArgs: (s) => ['--resume', s.id], crossToolArgs: (prompt) => [prompt], diff --git a/src/utils/index.ts b/src/utils/index.ts index 11a2ebd..911b3f8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -123,17 +123,23 @@ export function indexNeedsRebuild(source?: SessionSource): boolean { /** * Build the unified session index */ -export async function buildIndex(force = false): Promise { +export async function buildIndex(force = false, parseOptions?: SessionParseOptions): Promise { ensureDirectories(); // Check if we can use cached index if (!force && !indexNeedsRebuild()) { - return loadIndex(); + const cached = loadIndex(); + return parseOptions?.cwd ? cached.filter((session) => matchesCwd(session.cwd, parseOptions.cwd as string)) : cached; } // Parse all sessions from all sources in parallel — use allSettled so one // broken parser doesn't crash the entire CLI - const results = await Promise.allSettled(Object.values(adapters).map((a) => a.parseSessions())); + const results = await Promise.allSettled( + Object.values(adapters).map((adapter) => { + const options = parseOptions?.cwd && adapter.supportsCwdLookup ? parseOptions : undefined; + return options ? adapter.parseSessions(options) : adapter.parseSessions(); + }), + ); const allSessions = results .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') @@ -142,6 +148,10 @@ export async function buildIndex(force = false): Promise { // Sort by updated time (newest first) allSessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + // A cwd-scoped parse is intentionally not written to the global index: it + // contains only the requested scope for adapters with direct cwd lookup. + if (parseOptions?.cwd) return allSessions; + // Write to index file — first line is the env fingerprint writeIndexFile(INDEX_FILE, allSessions); @@ -239,7 +249,7 @@ export async function getSessionsBySource(source: SessionSource, forceRebuild = * Get current-working-directory sessions from the complete index. */ export async function getSessionsByCwd(cwd: string, forceRebuild = false): Promise { - const sessions = await buildIndex(forceRebuild); + const sessions = await buildIndex(forceRebuild, { cwd }); return sessions.filter((session) => matchesCwd(session.cwd, cwd)); } diff --git a/src/utils/slug.ts b/src/utils/slug.ts index d878e83..c35f50d 100644 --- a/src/utils/slug.ts +++ b/src/utils/slug.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs'; +import * as fs from 'node:fs'; import { IS_WINDOWS } from './platform.js'; /** @@ -11,9 +11,24 @@ import { IS_WINDOWS } from './platform.js'; */ export function cwdFromSlug(slug: string): string { const parts = slug.split('-'); - let best: string | null = null; const isDriveSlug = parts.length > 0 && /^[A-Za-z]$/.test(parts[0] || ''); + const fallbackPath = (): string => { + if (isDriveSlug && IS_WINDOWS) { + const drive = parts[0].toUpperCase(); + const rest = parts.slice(1).join('/'); + return rest ? `${drive}:/${rest}` : `${drive}:/`; + } + + return `/${slug.replace(/-/g, '/')}`; + }; + + // The recursive decoder has three branches per separator. Bound it so stale + // Cursor projects and unreachable mounts cannot monopolize the process. + if (parts[0] === 'Volumes' || parts.length > 12) return fallbackPath(); + + let best: string | null = null; + function candidatePaths(segments: string[]): string[] { const unixPath = '/' + segments.join('/'); if (segments.length > 0 && /^[A-Za-z]$/.test(segments[0] || '')) { @@ -60,14 +75,7 @@ export function cwdFromSlug(slug: string): string { resolve(0, []); if (best) return best; - - if (isDriveSlug && IS_WINDOWS) { - const drive = parts[0].toUpperCase(); - const rest = parts.slice(1).join('/'); - return rest ? `${drive}:/${rest}` : `${drive}:/`; - } - - return '/' + slug.replace(/-/g, '/'); + return fallbackPath(); } /**