Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/__tests__/cursor-parser.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 <sid>/subagents/', async () => {
// Per Cursor's documented agent-transcripts layout (observed in
// dev.to reverse-engineering article + VibeLens parser), sub-agent
Expand Down
17 changes: 14 additions & 3 deletions src/__tests__/cwd-from-slug.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
});
});
19 changes: 15 additions & 4 deletions src/__tests__/index-source-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ 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', () => ({
homeDir: () => testState.fakeHome,
}));

vi.mock('../parsers/registry.js', () => ({
ALL_TOOLS: ['claude', 'codex'],
ALL_TOOLS: ['claude', 'codex', 'cursor'],
adapters: {
claude: {
name: 'claude',
Expand All @@ -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,
},
},
}));

Expand All @@ -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(() => {
Expand Down Expand Up @@ -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' });
});
});
75 changes: 63 additions & 12 deletions src/parsers/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -226,15 +232,54 @@ async function readNormalizedTranscript(filePath: string): Promise<NormalizedCur
* filename — `getSessionId()` derives the UUID, and `parseCursorSessions()`
* deduplicates by id when both layouts coexist for the same session.
*/
async function findTranscriptFiles(): Promise<string[]> {
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<string | undefined> {
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<boolean> {
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<string[]> {
if (!fs.existsSync(CURSOR_PROJECTS_DIR)) return [];

const files: string[] = [];
try {
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,
Expand Down Expand Up @@ -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<string, string>): Promise<string> {
const fallback = cwdFromSlug(slug);
if (!projectDir) return fallback;
async function resolveProjectCwd(
projectDir: string,
slug: string,
cache: Map<string, string>,
cwdFallback?: string,
): Promise<string> {
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 = '';
Expand All @@ -326,7 +376,7 @@ async function resolveProjectCwd(projectDir: string, slug: string, cache: Map<st
}

cache.set(projectDir, resolved);
return resolved || fallback;
return resolved || fallback();
}

/**
Expand Down Expand Up @@ -392,10 +442,11 @@ async function parseSessionInfo(filePath: string): Promise<{
/**
* Parse all Cursor sessions
*/
export async function parseCursorSessions(): Promise<UnifiedSession[]> {
const files = await findTranscriptFiles();
export async function parseCursorSessions(options: SessionParseOptions = {}): Promise<UnifiedSession[]> {
const files = await findTranscriptFiles(options);
const sessionsById = new Map<string, UnifiedSession>();
const projectCwdCache = new Map<string, string>();
const cwdFallback = options.cwd ? path.resolve(options.cwd) : undefined;

for (const filePath of files) {
try {
Expand All @@ -406,7 +457,7 @@ export async function parseCursorSessions(): Promise<UnifiedSession[]> {
// `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);

Expand Down
1 change: 1 addition & 0 deletions src/parsers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,7 @@ register({
binaryName: 'cursor-agent',
binaryFallbacks: ['agent'],
parseSessions: parseCursorSessions,
supportsCwdLookup: true,
extractContext: extractCursorContext,
nativeResumeArgs: (s) => ['--resume', s.id],
crossToolArgs: (prompt) => [prompt],
Expand Down
18 changes: 14 additions & 4 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,23 @@ export function indexNeedsRebuild(source?: SessionSource): boolean {
/**
* Build the unified session index
*/
export async function buildIndex(force = false): Promise<UnifiedSession[]> {
export async function buildIndex(force = false, parseOptions?: SessionParseOptions): Promise<UnifiedSession[]> {
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<UnifiedSession[]> => r.status === 'fulfilled')
Expand All @@ -142,6 +148,10 @@ export async function buildIndex(force = false): Promise<UnifiedSession[]> {
// 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);

Expand Down Expand Up @@ -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<UnifiedSession[]> {
const sessions = await buildIndex(forceRebuild);
const sessions = await buildIndex(forceRebuild, { cwd });
return sessions.filter((session) => matchesCwd(session.cwd, cwd));
}

Expand Down
28 changes: 18 additions & 10 deletions src/utils/slug.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as fs from 'fs';
import * as fs from 'node:fs';
import { IS_WINDOWS } from './platform.js';

/**
Expand All @@ -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] || '')) {
Expand Down Expand Up @@ -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();
}

/**
Expand Down