* 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.
(customTitle?: string)
| 59 | * sessionId and adding forkedFrom traceability. |
| 60 | */ |
| 61 | async 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 |
no test coverage detected