import { randomUUID, type UUID } from 'crypto' import { mkdir, readFile, writeFile } from 'fs/promises' import { getOriginalCwd, getSessionId } 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, Entry, LogOption, SerializedMessage, TranscriptMessage, } from '../../types/logs.js' import { parseJSONL } from '../../utils/json.js' import { getProjectDir, getTranscriptPath, getTranscriptPathForSession, isTranscriptMessage, saveCustomTitle, searchSessionsByCustomTitle, } from '../../utils/sessionStorage.js' import { jsonStringify } from '../../utils/slowOperations.js' import { escapeRegExp } from '../../utils/stringUtils.js' type TranscriptEntry = TranscriptMessage & { forkedFrom?: { sessionId: string messageUuid: UUID } } /** * 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. */ export function deriveFirstPrompt( firstUserMessage: Extract | undefined, ): string { const content = firstUserMessage?.message?.content if (!content) return 'Branched conversation' const raw = typeof content === 'string' ? content : content.find( (block): block is { type: 'text'; text: string } => block.type === 'text', )?.text if (!raw) return 'Branched conversation' return ( raw.replace(/\s+/g, ' ').trim().slice(0, 100) || 'Branched conversation' ) } /** * Creates a fork of the current conversation by copying from the transcript file. * Preserves all original metadata (timestamps, gitBranch, etc.) while updating * sessionId and adding forkedFrom traceability. */ async function createFork(customTitle?: string): Promise<{ sessionId: UUID title: string | undefined forkPath: string serializedMessages: SerializedMessage[] contentReplacementRecords: ContentReplacementEntry['replacements'] }> { const forkSessionId = randomUUID() as UUID const originalSessionId = getSessionId() const projectDir = getProjectDir(getOriginalCwd()) const forkSessionPath = getTranscriptPathForSession(forkSessionId) const currentTranscriptPath = getTranscriptPath() // Ensure project directory exists await mkdir(projectDir, { recursive: true, mode: 0o700 }) // Read current transcript file let transcriptContent: Buffer try { transcriptContent = await readFile(currentTranscriptPath) } catch { throw new Error('No conversation to branch') } if (transcriptContent.length === 0) { throw new Error('No conversation to branch') } // Parse all transcript entries (messages + metadata entries like content-replacement) const entries = parseJSONL(transcriptContent) // Filter to only main conversation messages (exclude sidechains and non-message entries) const mainConversationEntries = entries.filter( (entry): entry is TranscriptMessage => isTranscriptMessage(entry) && !entry.isSidechain, ) // Content-replacement entries for the original session. These record which // tool_result blocks were replaced with previews by the per-message budget. // Without them in the fork JSONL, `claude -r {forkId}` reconstructs state // with an empty replacements Map → previously-replaced results are classified // as FROZEN and sent as full content (prompt cache miss + permanent overage). // sessionId must be rewritten since loadTranscriptFile keys lookup by the // session's messages' sessionId. const contentReplacementRecords = entries .filter( (entry): entry is ContentReplacementEntry => entry.type === 'content-replacement' && entry.sessionId === originalSessionId, ) .flatMap(entry => entry.replacements) if (mainConversationEntries.length === 0) { throw new Error('No messages to branch') } // Build forked entries with new sessionId and preserved metadata let parentUuid: UUID | null = null const lines: string[] = [] const serializedMessages: SerializedMessage[] = [] for (const entry of mainConversationEntries) { // Create forked transcript entry preserving all original metadata const forkedEntry: TranscriptEntry = { ...entry, sessionId: forkSessionId, parentUuid, isSidechain: false, forkedFrom: { sessionId: originalSessionId, messageUuid: entry.uuid, }, } // Build serialized message for LogOption const serialized: SerializedMessage = { ...entry, sessionId: forkSessionId, } serializedMessages.push(serialized) lines.push(jsonStringify(forkedEntry)) if (entry.type !== 'progress') { parentUuid = entry.uuid } } // Append content-replacement entry (if any) with the fork's sessionId. // Written as a SINGLE entry (same shape as insertContentReplacement) so // loadTranscriptFile's content-replacement branch picks it up. if (contentReplacementRecords.length > 0) { const forkedReplacementEntry: ContentReplacementEntry = { type: 'content-replacement', sessionId: forkSessionId, replacements: contentReplacementRecords, } lines.push(jsonStringify(forkedReplacementEntry)) } // Write the fork session file await writeFile(forkSessionPath, lines.join('\n') + '\n', { encoding: 'utf8', mode: 0o600, }) return { sessionId: forkSessionId, title: customTitle, forkPath: forkSessionPath, serializedMessages, contentReplacementRecords, } } /** * Generates a unique fork name by checking for collisions with existing session names. * If "baseName (Branch)" already exists, tries "baseName (Branch 2)", "baseName (Branch 3)", etc. */ async function getUniqueForkName(baseName: string): Promise { const candidateName = `${baseName} (Branch)` // Check if this exact name already exists const existingWithExactName = await searchSessionsByCustomTitle( candidateName, { exact: true }, ) if (existingWithExactName.length === 0) { return candidateName } // Name collision - find a unique numbered suffix // Search for all sessions that start with the base pattern const existingForks = await searchSessionsByCustomTitle(`${baseName} (Branch`) // Extract existing fork numbers to find the next available const usedNumbers = new Set([1]) // Consider " (Branch)" as number 1 const forkNumberPattern = new RegExp( `^${escapeRegExp(baseName)} \\(Branch(?: (\\d+))?\\)$`, ) for (const session of existingForks) { const match = session.customTitle?.match(forkNumberPattern) if (match) { if (match[1]) { usedNumbers.add(parseInt(match[1], 10)) } else { usedNumbers.add(1) // " (Branch)" without number is treated as 1 } } } // Find the next available number let nextNumber = 2 while (usedNumbers.has(nextNumber)) { nextNumber++ } return `${baseName} (Branch ${nextNumber})` } export async function call( onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext, args: string, ): Promise { const customTitle = args?.trim() || undefined const originalSessionId = getSessionId() try { const { sessionId, title, forkPath, serializedMessages, contentReplacementRecords, } = await createFork(customTitle) // Build LogOption for resume const now = new Date() const firstPrompt = deriveFirstPrompt( serializedMessages.find(m => m.type === 'user'), ) // Save custom title - use provided title or firstPrompt as default // This ensures /status and /resume show the same session name // Always add " (Branch)" suffix to make it clear this is a branched session // Handle collisions by adding a number suffix (e.g., " (Branch 2)", " (Branch 3)") const baseName = title ?? firstPrompt const effectiveTitle = await getUniqueForkName(baseName) await saveCustomTitle(sessionId, effectiveTitle, forkPath) logEvent('tengu_conversation_forked', { message_count: serializedMessages.length, has_custom_title: !!title, }) const forkLog: LogOption = { date: now.toISOString().split('T')[0]!, messages: serializedMessages, fullPath: forkPath, value: now.getTime(), created: now, modified: now, firstPrompt, messageCount: serializedMessages.length, isSidechain: false, sessionId, customTitle: effectiveTitle, contentReplacements: contentReplacementRecords, } // Resume into the fork const titleInfo = title ? ` "${title}"` : '' const resumeHint = `\nTo resume the original: claude -r ${originalSessionId}` const successMessage = `Branched conversation${titleInfo}. You are now in the branch.${resumeHint}` if (context.resume) { await context.resume(sessionId, forkLog, 'fork') onDone(successMessage, { display: 'system' }) } else { // Fallback if resume not available onDone( `Branched conversation${titleInfo}. Resume with: /resume ${sessionId}`, ) } return null } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error occurred' onDone(`Failed to branch conversation: ${message}`) return null } }