source dump of claude code
at main 296 lines 9.6 kB view raw
1import { randomUUID, type UUID } from 'crypto' 2import { mkdir, readFile, writeFile } from 'fs/promises' 3import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js' 4import type { LocalJSXCommandContext } from '../../commands.js' 5import { logEvent } from '../../services/analytics/index.js' 6import type { LocalJSXCommandOnDone } from '../../types/command.js' 7import type { 8 ContentReplacementEntry, 9 Entry, 10 LogOption, 11 SerializedMessage, 12 TranscriptMessage, 13} from '../../types/logs.js' 14import { parseJSONL } from '../../utils/json.js' 15import { 16 getProjectDir, 17 getTranscriptPath, 18 getTranscriptPathForSession, 19 isTranscriptMessage, 20 saveCustomTitle, 21 searchSessionsByCustomTitle, 22} from '../../utils/sessionStorage.js' 23import { jsonStringify } from '../../utils/slowOperations.js' 24import { escapeRegExp } from '../../utils/stringUtils.js' 25 26type TranscriptEntry = TranscriptMessage & { 27 forkedFrom?: { 28 sessionId: string 29 messageUuid: UUID 30 } 31} 32 33/** 34 * Derive a single-line title base from the first user message. 35 * Collapses whitespace — multiline first messages (pasted stacks, code) 36 * otherwise flow into the saved title and break the resume hint. 37 */ 38export function deriveFirstPrompt( 39 firstUserMessage: Extract<SerializedMessage, { type: 'user' }> | undefined, 40): string { 41 const content = firstUserMessage?.message?.content 42 if (!content) return 'Branched conversation' 43 const raw = 44 typeof content === 'string' 45 ? content 46 : content.find( 47 (block): block is { type: 'text'; text: string } => 48 block.type === 'text', 49 )?.text 50 if (!raw) return 'Branched conversation' 51 return ( 52 raw.replace(/\s+/g, ' ').trim().slice(0, 100) || 'Branched conversation' 53 ) 54} 55 56/** 57 * Creates a fork of the current conversation by copying from the transcript file. 58 * Preserves all original metadata (timestamps, gitBranch, etc.) while updating 59 * sessionId and adding forkedFrom traceability. 60 */ 61async function createFork(customTitle?: string): Promise<{ 62 sessionId: UUID 63 title: string | undefined 64 forkPath: string 65 serializedMessages: SerializedMessage[] 66 contentReplacementRecords: ContentReplacementEntry['replacements'] 67}> { 68 const forkSessionId = randomUUID() as UUID 69 const originalSessionId = getSessionId() 70 const projectDir = getProjectDir(getOriginalCwd()) 71 const forkSessionPath = getTranscriptPathForSession(forkSessionId) 72 const currentTranscriptPath = getTranscriptPath() 73 74 // Ensure project directory exists 75 await mkdir(projectDir, { recursive: true, mode: 0o700 }) 76 77 // Read current transcript file 78 let transcriptContent: Buffer 79 try { 80 transcriptContent = await readFile(currentTranscriptPath) 81 } catch { 82 throw new Error('No conversation to branch') 83 } 84 85 if (transcriptContent.length === 0) { 86 throw new Error('No conversation to branch') 87 } 88 89 // Parse all transcript entries (messages + metadata entries like content-replacement) 90 const entries = parseJSONL<Entry>(transcriptContent) 91 92 // Filter to only main conversation messages (exclude sidechains and non-message entries) 93 const mainConversationEntries = entries.filter( 94 (entry): entry is TranscriptMessage => 95 isTranscriptMessage(entry) && !entry.isSidechain, 96 ) 97 98 // Content-replacement entries for the original session. These record which 99 // tool_result blocks were replaced with previews by the per-message budget. 100 // Without them in the fork JSONL, `claude -r {forkId}` reconstructs state 101 // with an empty replacements Map → previously-replaced results are classified 102 // as FROZEN and sent as full content (prompt cache miss + permanent overage). 103 // sessionId must be rewritten since loadTranscriptFile keys lookup by the 104 // session's messages' sessionId. 105 const contentReplacementRecords = entries 106 .filter( 107 (entry): entry is ContentReplacementEntry => 108 entry.type === 'content-replacement' && 109 entry.sessionId === originalSessionId, 110 ) 111 .flatMap(entry => entry.replacements) 112 113 if (mainConversationEntries.length === 0) { 114 throw new Error('No messages to branch') 115 } 116 117 // Build forked entries with new sessionId and preserved metadata 118 let parentUuid: UUID | null = null 119 const lines: string[] = [] 120 const serializedMessages: SerializedMessage[] = [] 121 122 for (const entry of mainConversationEntries) { 123 // Create forked transcript entry preserving all original metadata 124 const forkedEntry: TranscriptEntry = { 125 ...entry, 126 sessionId: forkSessionId, 127 parentUuid, 128 isSidechain: false, 129 forkedFrom: { 130 sessionId: originalSessionId, 131 messageUuid: entry.uuid, 132 }, 133 } 134 135 // Build serialized message for LogOption 136 const serialized: SerializedMessage = { 137 ...entry, 138 sessionId: forkSessionId, 139 } 140 141 serializedMessages.push(serialized) 142 lines.push(jsonStringify(forkedEntry)) 143 if (entry.type !== 'progress') { 144 parentUuid = entry.uuid 145 } 146 } 147 148 // Append content-replacement entry (if any) with the fork's sessionId. 149 // Written as a SINGLE entry (same shape as insertContentReplacement) so 150 // loadTranscriptFile's content-replacement branch picks it up. 151 if (contentReplacementRecords.length > 0) { 152 const forkedReplacementEntry: ContentReplacementEntry = { 153 type: 'content-replacement', 154 sessionId: forkSessionId, 155 replacements: contentReplacementRecords, 156 } 157 lines.push(jsonStringify(forkedReplacementEntry)) 158 } 159 160 // Write the fork session file 161 await writeFile(forkSessionPath, lines.join('\n') + '\n', { 162 encoding: 'utf8', 163 mode: 0o600, 164 }) 165 166 return { 167 sessionId: forkSessionId, 168 title: customTitle, 169 forkPath: forkSessionPath, 170 serializedMessages, 171 contentReplacementRecords, 172 } 173} 174 175/** 176 * Generates a unique fork name by checking for collisions with existing session names. 177 * If "baseName (Branch)" already exists, tries "baseName (Branch 2)", "baseName (Branch 3)", etc. 178 */ 179async function getUniqueForkName(baseName: string): Promise<string> { 180 const candidateName = `${baseName} (Branch)` 181 182 // Check if this exact name already exists 183 const existingWithExactName = await searchSessionsByCustomTitle( 184 candidateName, 185 { exact: true }, 186 ) 187 188 if (existingWithExactName.length === 0) { 189 return candidateName 190 } 191 192 // Name collision - find a unique numbered suffix 193 // Search for all sessions that start with the base pattern 194 const existingForks = await searchSessionsByCustomTitle(`${baseName} (Branch`) 195 196 // Extract existing fork numbers to find the next available 197 const usedNumbers = new Set<number>([1]) // Consider " (Branch)" as number 1 198 const forkNumberPattern = new RegExp( 199 `^${escapeRegExp(baseName)} \\(Branch(?: (\\d+))?\\)$`, 200 ) 201 202 for (const session of existingForks) { 203 const match = session.customTitle?.match(forkNumberPattern) 204 if (match) { 205 if (match[1]) { 206 usedNumbers.add(parseInt(match[1], 10)) 207 } else { 208 usedNumbers.add(1) // " (Branch)" without number is treated as 1 209 } 210 } 211 } 212 213 // Find the next available number 214 let nextNumber = 2 215 while (usedNumbers.has(nextNumber)) { 216 nextNumber++ 217 } 218 219 return `${baseName} (Branch ${nextNumber})` 220} 221 222export async function call( 223 onDone: LocalJSXCommandOnDone, 224 context: LocalJSXCommandContext, 225 args: string, 226): Promise<React.ReactNode> { 227 const customTitle = args?.trim() || undefined 228 229 const originalSessionId = getSessionId() 230 231 try { 232 const { 233 sessionId, 234 title, 235 forkPath, 236 serializedMessages, 237 contentReplacementRecords, 238 } = await createFork(customTitle) 239 240 // Build LogOption for resume 241 const now = new Date() 242 const firstPrompt = deriveFirstPrompt( 243 serializedMessages.find(m => m.type === 'user'), 244 ) 245 246 // Save custom title - use provided title or firstPrompt as default 247 // This ensures /status and /resume show the same session name 248 // Always add " (Branch)" suffix to make it clear this is a branched session 249 // Handle collisions by adding a number suffix (e.g., " (Branch 2)", " (Branch 3)") 250 const baseName = title ?? firstPrompt 251 const effectiveTitle = await getUniqueForkName(baseName) 252 await saveCustomTitle(sessionId, effectiveTitle, forkPath) 253 254 logEvent('tengu_conversation_forked', { 255 message_count: serializedMessages.length, 256 has_custom_title: !!title, 257 }) 258 259 const forkLog: LogOption = { 260 date: now.toISOString().split('T')[0]!, 261 messages: serializedMessages, 262 fullPath: forkPath, 263 value: now.getTime(), 264 created: now, 265 modified: now, 266 firstPrompt, 267 messageCount: serializedMessages.length, 268 isSidechain: false, 269 sessionId, 270 customTitle: effectiveTitle, 271 contentReplacements: contentReplacementRecords, 272 } 273 274 // Resume into the fork 275 const titleInfo = title ? ` "${title}"` : '' 276 const resumeHint = `\nTo resume the original: claude -r ${originalSessionId}` 277 const successMessage = `Branched conversation${titleInfo}. You are now in the branch.${resumeHint}` 278 279 if (context.resume) { 280 await context.resume(sessionId, forkLog, 'fork') 281 onDone(successMessage, { display: 'system' }) 282 } else { 283 // Fallback if resume not available 284 onDone( 285 `Branched conversation${titleInfo}. Resume with: /resume ${sessionId}`, 286 ) 287 } 288 289 return null 290 } catch (error) { 291 const message = 292 error instanceof Error ? error.message : 'Unknown error occurred' 293 onDone(`Failed to branch conversation: ${message}`) 294 return null 295 } 296}