From 7b154fa4087074a2f48a4f7bdbbe37cf7f9bef27 Mon Sep 17 00:00:00 2001 From: chenshengguang Date: Thu, 20 Aug 2026 17:30:04 +0800 Subject: [PATCH 1/2] session import --- src/commands.ts | 2 + .../__tests__/sessionImport.test.ts | 238 +++++++++++++ src/commands/session-import/index.ts | 12 + src/commands/session-import/sessionImport.ts | 313 ++++++++++++++++++ 4 files changed, 565 insertions(+) create mode 100644 src/commands/session-import/__tests__/sessionImport.test.ts create mode 100644 src/commands/session-import/index.ts create mode 100644 src/commands/session-import/sessionImport.ts diff --git a/src/commands.ts b/src/commands.ts index bcfb6399f4..6702b3a2cd 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -24,6 +24,7 @@ import help from './commands/help/index.js' import ide from './commands/ide/index.js' import init from './commands/init.js' import initVerifiers from './commands/init-verifiers.js' +import sessionImportCommand from './commands/session-import/index.js' import keybindings from './commands/keybindings/index.js' import lang from './commands/lang/index.js' import login from './commands/login/index.js' @@ -347,6 +348,7 @@ const COMMANDS = memoize((): Command[] => [ rename, resume, session, + sessionImportCommand, skills, status, statusline, diff --git a/src/commands/session-import/__tests__/sessionImport.test.ts b/src/commands/session-import/__tests__/sessionImport.test.ts new file mode 100644 index 0000000000..745a5e7e81 --- /dev/null +++ b/src/commands/session-import/__tests__/sessionImport.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const { sessionImport } = await import('../sessionImport.js') + +let tempDir: string +let sourceDir: string +let originalConfigDir: string | undefined + +beforeEach(() => { + tempDir = join( + tmpdir(), + `claude-import-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ) + // sessionImport writes to getProjectDir(originalCwd) = + // ${CLAUDE_CONFIG_DIR}/projects// — pre-create projects/. + mkdirSync(join(tempDir, 'projects'), { recursive: true }) + sourceDir = join(tempDir, 'src') + mkdirSync(sourceDir, { recursive: true }) + // Pin session-file paths to the temp dir so tests never touch the real + // ~/.claude/projects (same hermetic pattern as sessionStorage.test.ts). + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tempDir +}) + +afterEach(() => { + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }) + } +}) + +const SRC_SESSION = '11111111-1111-4111-8111-111111111111' + +function userEntry( + uuid: string, + parentUuid: string | null, + timestamp: string, + text: string, + extra: Record = {}, +) { + return { + type: 'user', + uuid, + parentUuid, + sessionId: SRC_SESSION, + isSidechain: false, + cwd: tempDir, + timestamp, + version: '1.0.0', + message: { role: 'user', content: text }, + ...extra, + } +} + +function assistantEntry( + uuid: string, + parentUuid: string | null, + timestamp: string, + text: string, +) { + return { + type: 'assistant', + uuid, + parentUuid, + sessionId: SRC_SESSION, + isSidechain: false, + cwd: tempDir, + timestamp, + version: '1.0.0', + message: { + role: 'assistant', + content: [{ type: 'text', text }], + }, + } +} + +function writeSource(entries: object[]): string { + const path = join(sourceDir, 'source.jsonl') + writeFileSync(path, entries.map(e => JSON.stringify(e)).join('\n') + '\n') + return path +} + +describe('sessionImport', () => { + test('imports the active chain as a new session, dropping dead branches and sidechains', async () => { + const sourcePath = writeSource([ + userEntry('u1', null, '2026-08-13T10:00:00.000Z', 'first question'), + assistantEntry('a1', 'u1', '2026-08-13T10:00:05.000Z', 'answer one'), + // Dead branch: another child of a1, but the live continuation (u2) + // was written later — newest leaf wins, d1 must be dropped. + userEntry('d1', 'a1', '2026-08-13T10:00:30.000Z', 'dead branch'), + userEntry('u2', 'a1', '2026-08-13T10:01:00.000Z', 'second question'), + // Sidechain (subagent) messages never enter the imported main chain. + userEntry('s1', null, '2026-08-13T10:00:20.000Z', 'sidechain prompt', { + isSidechain: true, + agentId: 'agent-1', + }), + { + type: 'content-replacement', + sessionId: SRC_SESSION, + replacements: [ + { kind: 'tool-result', toolUseId: 'toolu_1', replacement: '' }, + ], + }, + ]) + + const result = await sessionImport(sourcePath) + + expect(result.sessionId).not.toBe(SRC_SESSION) + expect(result.importPath.startsWith(tempDir)).toBe(true) + expect(existsSync(result.importPath)).toBe(true) + + const { readFileSync } = await import('node:fs') + const lines = readFileSync(result.importPath, 'utf8') + .trim() + .split('\n') + .map(line => JSON.parse(line)) + expect(lines).toHaveLength(4) // 3 chain messages + 1 replacement entry + + const [first, second, third, replacement] = lines + // Session id rewritten everywhere; original chain order/parents kept + expect(first.uuid).toBe('u1') + expect(first.parentUuid).toBeNull() + expect(first.sessionId).toBe(result.sessionId) + expect(first.importedFrom).toEqual({ path: resolve(sourcePath) }) + expect(second.uuid).toBe('a1') + expect(second.parentUuid).toBe('u1') + expect(second.sessionId).toBe(result.sessionId) + expect(third.uuid).toBe('u2') + expect(third.parentUuid).toBe('a1') + expect(third.sessionId).toBe(result.sessionId) + // Dead branch + sidechain excluded + const uuids = lines.map(l => l.uuid) + expect(uuids).not.toContain('d1') + expect(uuids).not.toContain('s1') + + // Content-replacement copied with the new session id + expect(replacement.type).toBe('content-replacement') + expect(replacement.sessionId).toBe(result.sessionId) + expect(replacement.replacements[0].toolUseId).toBe('toolu_1') + expect(result.contentReplacementRecords).toHaveLength(1) + + // serializedMessages for the LogOption carry the new session id + expect(result.serializedMessages.map(m => (m as any).uuid)).toEqual([ + 'u1', + 'a1', + 'u2', + ]) + }) + + test('rejects a missing file', async () => { + await expect(sessionImport(join(sourceDir, 'nope.jsonl'))).rejects.toThrow( + /File not found/, + ) + }) + + test('rejects an empty transcript', async () => { + const sourcePath = join(sourceDir, 'empty.jsonl') + writeFileSync(sourcePath, '') + await expect(sessionImport(sourcePath)).rejects.toThrow( + /No messages to import/, + ) + }) + + test('rejects a transcript with only sidechain messages', async () => { + const sourcePath = writeSource([ + userEntry('s1', null, '2026-08-13T10:00:00.000Z', 'sidechain only', { + isSidechain: true, + agentId: 'agent-1', + }), + ]) + await expect(sessionImport(sourcePath)).rejects.toThrow( + /No messages to import/, + ) + }) + + test('rewrites the compact summary transcript path to the import path', async () => { + const oldPath = '/home/other-user/.claude/projects/old-project/aaaa.jsonl' + const sourcePath = writeSource([ + userEntry('u1', null, '2026-08-13T10:00:00.000Z', 'first question'), + assistantEntry('a1', 'u1', '2026-08-13T10:00:05.000Z', 'long answer'), + { + type: 'system', + subtype: 'compact_boundary', + uuid: 'b1', + parentUuid: 'a1', + sessionId: SRC_SESSION, + isSidechain: false, + cwd: tempDir, + timestamp: '2026-08-13T10:10:00.000Z', + version: '1.0.0', + compactMetadata: {}, + }, + userEntry( + 'c1', + 'b1', + '2026-08-13T10:10:05.000Z', + `This session is being continued from a previous conversation that ran out of context.\n\nSummary:\nDid things.\n\nIf you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: ${oldPath}\n\nRecent messages are preserved verbatim.`, + { isCompactSummary: true, isVisibleInTranscriptOnly: true }, + ), + ]) + + const result = await sessionImport(sourcePath) + + const { readFileSync } = await import('node:fs') + const lines = readFileSync(result.importPath, 'utf8') + .trim() + .split('\n') + .map(line => JSON.parse(line)) + const summary = lines.find(l => l.uuid === 'c1') + expect(summary.isCompactSummary).toBe(true) + expect(summary.message.content).toContain( + `read the full transcript at: ${result.importPath}`, + ) + expect(summary.message.content).not.toContain(oldPath) + // Suffix after the path line survives the rewrite + expect(summary.message.content).toContain( + 'Recent messages are preserved verbatim.', + ) + // Non-summary messages untouched + expect(lines.find(l => l.uuid === 'u1').message.content).toBe( + 'first question', + ) + // serializedMessages (LogOption/resume path) also carries the rewrite + const serializedSummary = result.serializedMessages.find( + m => (m as any).uuid === 'c1', + ) as any + expect(serializedSummary.message.content).toContain( + `read the full transcript at: ${result.importPath}`, + ) + }) +}) diff --git a/src/commands/session-import/index.ts b/src/commands/session-import/index.ts new file mode 100644 index 0000000000..9877ced99b --- /dev/null +++ b/src/commands/session-import/index.ts @@ -0,0 +1,12 @@ +import type { Command } from '../../commands.js' + +const sessionImportCommand = { + type: 'local-jsx', + name: 'session-import', + description: + 'Import a conversation transcript (.jsonl) as a new session and continue it', + argumentHint: '', + load: () => import('./sessionImport.js'), +} satisfies Command + +export default sessionImportCommand diff --git a/src/commands/session-import/sessionImport.ts b/src/commands/session-import/sessionImport.ts new file mode 100644 index 0000000000..5d4198a341 --- /dev/null +++ b/src/commands/session-import/sessionImport.ts @@ -0,0 +1,313 @@ +import { randomUUID, type UUID } from 'crypto' +import { mkdir, stat, writeFile } from 'fs/promises' +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' +import { getOriginalCwd } from '../../bootstrap/state.js' +import type { LocalJSXCommandContext } from '../../commands.js' +import { logEvent } from '../../services/analytics/index.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' +import type { + ContentReplacementEntry, + LogOption, + SerializedMessage, + TranscriptMessage, +} from '../../types/logs.js' +import { + buildConversationChain, + getProjectDir, + getTranscriptPathForSession, + loadTranscriptFile, + saveCustomTitle, + searchSessionsByCustomTitle, +} from '../../utils/sessionStorage.js' +import type { ContentReplacementRecord } from '../../utils/toolResultStorage.js' +import { jsonStringify } from '../../utils/slowOperations.js' +import { escapeRegExp } from '../../utils/stringUtils.js' + +type ImportedTranscriptEntry = TranscriptMessage & { + importedFrom?: { + path: string + } +} + +// The compact summary embeds the transcript path captured at compact time +// ("read the full transcript at: "). The import carries the full +// pre-compact chain, so repoint the hint at the imported file — the original +// absolute path is usually dead on another machine (and 30-day cleanup can +// remove it even on the same one). Path runs to end of line because the +// suffixes appended after it (e.g. "Recent messages are preserved verbatim.") +// always start on a new line. +const COMPACT_TRANSCRIPT_PATH_RE = /read the full transcript at: [^\n]+/g + +function rewriteCompactSummaryPath( + entry: TranscriptMessage, + importPath: string, +): TranscriptMessage { + const message = entry.message + if (!message) return entry + const rewrite = (text: string): string => + text.replace( + COMPACT_TRANSCRIPT_PATH_RE, + `read the full transcript at: ${importPath}`, + ) + let content = message.content + if (typeof content === 'string') { + content = rewrite(content) + } else if (Array.isArray(content)) { + content = content.map(block => + block && typeof block === 'object' && block.type === 'text' + ? { ...block, text: rewrite(block.text) } + : block, + ) + } else { + return entry + } + return { ...entry, message: { ...message, content } } +} + +function resolveSourcePath(raw: string): string { + const expanded = + raw === '~' || raw.startsWith('~/') ? join(homedir(), raw.slice(1)) : raw + return resolve(getOriginalCwd(), expanded) +} + +/** + * Derive a single-line title base from the first user message. + * Collapses whitespace — multiline first messages (pasted stacks, code) + * otherwise flow into the saved title and break the resume hint. + */ +function deriveFirstPrompt( + firstUserMessage: Extract | undefined, +): string { + const content = (firstUserMessage as any)?.message?.content + if (!content) return 'Imported conversation' + const raw = + typeof content === 'string' + ? content + : content.find( + (block: { + type: string + text?: string + }): block is { type: 'text'; text: string } => block.type === 'text', + )?.text + if (!raw) return 'Imported conversation' + return ( + raw.replace(/\s+/g, ' ').trim().slice(0, 100) || 'Imported conversation' + ) +} + +/** + * Imports a conversation from an external JSONL transcript as a new session + * in the current project dir. + * + * Unlike /branch (which copies every main-chain entry in file order and + * re-threads parentUuid linearly), this walks the ACTIVE chain — newest + * non-sidechain leaf back via parentUuid — so rewind/branch dead branches in + * the source file are dropped, and parallel tool_use DAG structure is + * preserved. Original uuid/parentUuid are kept (self-consistent within the + * chain); only sessionId is rewritten to the fresh import id, which avoids + * collisions when the source session already exists on this machine. + */ +export async function sessionImport(sourcePath: string): Promise<{ + sessionId: UUID + importPath: string + serializedMessages: SerializedMessage[] + contentReplacementRecords: ContentReplacementRecord[] +}> { + const resolvedPath = resolveSourcePath(sourcePath) + try { + await stat(resolvedPath) + } catch { + throw new Error(`File not found: ${resolvedPath}`) + } + + const { messages, leafUuids, contentReplacements } = + await loadTranscriptFile(resolvedPath) + + // Newest non-sidechain leaf = the live conversation tip. Dead-branch tips + // are also leaves, but a rewind always writes the continuation afterwards, + // so the newest timestamp wins. Same walk as loadMessagesFromJsonlPath. + let tip: TranscriptMessage | null = null + let tipTs = 0 + for (const m of messages.values()) { + if (m.isSidechain || !leafUuids.has(m.uuid)) continue + const ts = new Date(m.timestamp).getTime() + if (ts > tipTs) { + tipTs = ts + tip = m + } + } + if (!tip) throw new Error('No messages to import') + + const chain = buildConversationChain(messages, tip) + const replacementRecords = [...contentReplacements.values()].flat() + + const importSessionId = randomUUID() as UUID + const projectDir = getProjectDir(getOriginalCwd()) + const importPath = getTranscriptPathForSession(importSessionId) + + await mkdir(projectDir, { recursive: true, mode: 0o700 }) + + const lines: string[] = [] + const serializedMessages: SerializedMessage[] = [] + + for (const [index, entry] of chain.entries()) { + const rewritten = + entry.type === 'user' && entry.isCompactSummary + ? rewriteCompactSummaryPath(entry, importPath) + : entry + const importedEntry: ImportedTranscriptEntry = { + ...rewritten, + sessionId: importSessionId, + isSidechain: false, + ...(index === 0 ? { importedFrom: { path: resolvedPath } } : {}), + } + serializedMessages.push({ ...rewritten, sessionId: importSessionId }) + lines.push(jsonStringify(importedEntry)) + } + + // Content-replacement entries record which tool_result blocks were replaced + // with previews by the per-message budget. Without them, resume reconstructs + // state with an empty replacements Map → previously-replaced results are + // classified as FROZEN and sent as full content (prompt cache miss + + // permanent overage). Written as a SINGLE entry so loadTranscriptFile's + // content-replacement branch picks it up. + if (replacementRecords.length > 0) { + const replacementEntry: ContentReplacementEntry = { + type: 'content-replacement', + sessionId: importSessionId, + replacements: replacementRecords, + } + lines.push(jsonStringify(replacementEntry)) + } + + await writeFile(importPath, lines.join('\n') + '\n', { + encoding: 'utf8', + mode: 0o600, + }) + + return { + sessionId: importSessionId, + importPath, + serializedMessages, + contentReplacementRecords: replacementRecords, + } +} + +/** + * Generates a unique import name by checking for collisions with existing + * session names. If "baseName (Imported)" already exists, tries + * "baseName (Imported 2)", "baseName (Imported 3)", etc. + */ +async function getUniqueImportName(baseName: string): Promise { + const candidateName = `${baseName} (Imported)` + + const existingWithExactName = await searchSessionsByCustomTitle( + candidateName, + { exact: true }, + ) + if (existingWithExactName.length === 0) { + return candidateName + } + + const existingImports = await searchSessionsByCustomTitle( + `${baseName} (Imported`, + ) + const usedNumbers = new Set([1]) + const importNumberPattern = new RegExp( + `^${escapeRegExp(baseName)} \\(Imported(?: (\\d+))?\\)$`, + ) + for (const session of existingImports) { + const match = session.customTitle?.match(importNumberPattern) + if (match) { + if (match[1]) { + usedNumbers.add(parseInt(match[1], 10)) + } else { + usedNumbers.add(1) + } + } + } + + let nextNumber = 2 + while (usedNumbers.has(nextNumber)) { + nextNumber++ + } + return `${baseName} (Imported ${nextNumber})` +} + +export async function call( + onDone: LocalJSXCommandOnDone, + context: LocalJSXCommandContext, + args: string, +): Promise { + const sourcePath = args?.trim() + + if (!sourcePath) { + onDone( + [ + 'Usage: /session-import ', + '', + 'Imports a conversation transcript (e.g. a session file copied from ~/.claude/projects)', + 'as a new session and continues it.', + ].join('\n'), + ) + return null + } + + try { + const { + sessionId, + importPath, + serializedMessages, + contentReplacementRecords, + } = await sessionImport(sourcePath) + + const now = new Date() + const firstPrompt = deriveFirstPrompt( + serializedMessages.find(m => m.type === 'user') as + | Extract + | undefined, + ) + + // Title the imported session so /status and /resume show the same name. + // " (Imported)" suffix marks provenance; numbered suffix resolves collisions. + const effectiveTitle = await getUniqueImportName(firstPrompt) + await saveCustomTitle(sessionId, effectiveTitle, importPath) + + logEvent('tengu_conversation_imported', { + message_count: serializedMessages.length, + }) + + const importLog: LogOption = { + date: now.toISOString().split('T')[0]!, + messages: serializedMessages, + fullPath: importPath, + value: now.getTime(), + created: now, + modified: now, + firstPrompt, + messageCount: serializedMessages.length, + isSidechain: false, + sessionId, + customTitle: effectiveTitle, + contentReplacements: contentReplacementRecords, + } + + const successMessage = `Imported ${serializedMessages.length} messages. You are now in the imported session.\nTo resume later: claude -r ${sessionId}` + + if (context.resume) { + await context.resume(sessionId, importLog, 'fork') + onDone(successMessage, { display: 'system' }) + } else { + onDone( + `Imported ${serializedMessages.length} messages. Resume with: /resume ${sessionId}`, + ) + } + return null + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown error occurred' + onDone(`Failed to import conversation: ${message}`) + return null + } +} From 6289e7752d0ca1112804b61679c79f5837991426 Mon Sep 17 00:00:00 2001 From: chenshengguang Date: Sat, 22 Aug 2026 16:14:18 +0800 Subject: [PATCH 2/2] fix:test --- .../__tests__/sessionImport.test.ts | 39 ++++++++++++++++++- src/commands/session-import/sessionImport.ts | 15 ++++--- src/utils/sessionStorage.ts | 14 +++++-- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/commands/session-import/__tests__/sessionImport.test.ts b/src/commands/session-import/__tests__/sessionImport.test.ts index 745a5e7e81..114f6aea53 100644 --- a/src/commands/session-import/__tests__/sessionImport.test.ts +++ b/src/commands/session-import/__tests__/sessionImport.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { join, resolve, dirname } from 'node:path' const { sessionImport } = await import('../sessionImport.js') @@ -154,6 +154,43 @@ describe('sessionImport', () => { ]) }) + test('reserves import suffixes case-insensitively when deriving a unique title', async () => { + const sourcePath = writeSource([ + userEntry('u1', null, '2026-08-13T10:00:00.000Z', 'first question'), + ]) + + // Establish the project dir sessionImport writes into (getProjectDir of + // the original cwd) so the seeded titles below are discoverable. + const { importPath } = await sessionImport(sourcePath) + const projectDir = dirname(importPath) + + // Seed existing sessions whose titles collide case-insensitively with + // candidates for base name "first question": the plain suffix exists, + // and suffix 2 exists only in different casing. + const seedTitle = (sessionId: string, title: string) => { + writeFileSync( + join(projectDir, `${sessionId}.jsonl`), + `${JSON.stringify({ type: 'custom-title', customTitle: title, sessionId })}\n`, + ) + } + seedTitle( + '22222222-2222-4222-8222-222222222222', + 'first question (Imported)', + ) + seedTitle( + '33333333-3333-4333-8333-333333333333', + 'FIRST QUESTION (Imported 2)', + ) + + const { getUniqueImportName } = await import('../sessionImport.js') + + // searchSessionsByCustomTitle matches titles case-insensitively, so + // suffix 2 is already taken by the seed — the next free suffix is 3. + expect(await getUniqueImportName('first question')).toBe( + 'first question (Imported 3)', + ) + }) + test('rejects a missing file', async () => { await expect(sessionImport(join(sourceDir, 'nope.jsonl'))).rejects.toThrow( /File not found/, diff --git a/src/commands/session-import/sessionImport.ts b/src/commands/session-import/sessionImport.ts index 5d4198a341..555c1dbaa6 100644 --- a/src/commands/session-import/sessionImport.ts +++ b/src/commands/session-import/sessionImport.ts @@ -24,6 +24,8 @@ import type { ContentReplacementRecord } from '../../utils/toolResultStorage.js' import { jsonStringify } from '../../utils/slowOperations.js' import { escapeRegExp } from '../../utils/stringUtils.js' +type SerializedUserMessage = SerializedMessage & { type: 'user' } + type ImportedTranscriptEntry = TranscriptMessage & { importedFrom?: { path: string @@ -77,9 +79,9 @@ function resolveSourcePath(raw: string): string { * otherwise flow into the saved title and break the resume hint. */ function deriveFirstPrompt( - firstUserMessage: Extract | undefined, + firstUserMessage: SerializedUserMessage | undefined, ): string { - const content = (firstUserMessage as any)?.message?.content + const content = firstUserMessage?.message?.content if (!content) return 'Imported conversation' const raw = typeof content === 'string' @@ -199,7 +201,7 @@ export async function sessionImport(sourcePath: string): Promise<{ * session names. If "baseName (Imported)" already exists, tries * "baseName (Imported 2)", "baseName (Imported 3)", etc. */ -async function getUniqueImportName(baseName: string): Promise { +export async function getUniqueImportName(baseName: string): Promise { const candidateName = `${baseName} (Imported)` const existingWithExactName = await searchSessionsByCustomTitle( @@ -216,6 +218,7 @@ async function getUniqueImportName(baseName: string): Promise { const usedNumbers = new Set([1]) const importNumberPattern = new RegExp( `^${escapeRegExp(baseName)} \\(Imported(?: (\\d+))?\\)$`, + 'i', ) for (const session of existingImports) { const match = session.customTitle?.match(importNumberPattern) @@ -264,9 +267,9 @@ export async function call( const now = new Date() const firstPrompt = deriveFirstPrompt( - serializedMessages.find(m => m.type === 'user') as - | Extract - | undefined, + serializedMessages.find( + (m): m is SerializedUserMessage => m.type === 'user', + ), ) // Title the imported session so /status and /resume show the same name. diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index d6da26184a..b4b2b5e475 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -434,9 +434,17 @@ export function isCustomTitleEnabled(): boolean { // string; homedir/env/regex are all session-invariant so the result is // stable for a given input. Worktree switches just change the key — no // cache clear needed. -export const getProjectDir = memoize((projectDir: string): string => { - return join(getProjectsDir(), sanitizePath(projectDir)) -}) +// +// Resolver keys off CLAUDE_CONFIG_DIR too: getProjectsDir() reads that env +// var, so a test (or runtime config swap) that changes it must invalidate +// the cache or callers silently read/write the wrong project dir. +export const getProjectDir = memoize( + (projectDir: string): string => { + return join(getProjectsDir(), sanitizePath(projectDir)) + }, + (projectDir: string) => + `${process.env.CLAUDE_CONFIG_DIR ?? ''}:${projectDir}`, +) let project: Project | null = null let cleanupRegistered = false