Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
9.6 kB · 296 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297import { 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<SerializedMessage, { type: 'user' }> | 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<Entry>(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<string> { 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<number>([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<React.ReactNode> { 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 }}