Repository navigation
Seed Claude transcripts into the target project dir on fork/resume - #5943
lawrencecchen wants to merge 4 commits into
Conversation
…d (red) Forking or restoring a Claude conversation into a workspace whose cwd differs from the session's origin fails with "No conversation found" because claude --resume only searches projects/<encoded-cwd>/. #5941 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
claude --resume <id> only searches projects/<encoded-cwd>/ of the current working directory, so forking or restoring a conversation into a different directory failed with "No conversation found". ClaudeSessionTranscriptSeeder copies the transcript (and its optional sidecar dir) into the target cwd's project dir at launch time, inside the config root the resumed claude will actually read (captured CLAUDE_CONFIG_DIR first, then process env, then ~/.claude). Wired into the shared startup-input funnel so fork-to-tab, fork-to-split, fork-to-new-workspace, and session restore all benefit. Fixes #5941 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds ClaudeSessionTranscriptSeeder to copy Claude session JSONL transcripts (and optional sidecars) into the encoded project directory for a target working directory, and invokes seeding from restorable session resume/fork startup paths before generating launcher commands or inline input. ChangesClaude Session Transcript Seeding
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (2 errors, 1 warning, 1 inconclusive)
✅ Passed checks (17 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6cf532c. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6cf532c0cc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return String(resolved.map { character in | ||
| character.isASCII && (character.isLetter || character.isNumber) ? character : "-" | ||
| }) |
There was a problem hiding this comment.
Use the existing Claude project-dir encoder
For target cwd paths containing other non-alphanumeric characters such as underscores or spaces, this new encoder seeds the transcript under a different directory than the rest of cmux still treats as Claude's project directory (RestorableAgentSessionIndex.encodeClaudeProjectDir and its tests only replace / and .). In that case the startup command still cds into the target cwd, but the copied transcript is placed in the mismatched projects/<new-encoding>/ bucket, so claude --resume can continue to report no conversation found. Please share one encoder/contract (or update all call sites/tests together) so the seed location matches the resume lookup location.
Useful? React with 👍 / 👎.
Greptile SummaryAdds
Confidence Score: 4/5Safe to merge after addressing the stale-snapshot selection bug; all other paths are well-covered by tests and the cross-configDir write is correctly fixed. The source-selection loop in seedIfNeeded returns the first .jsonl found in contentsOfDirectory order, which is non-deterministic. After a session is forked once (creating a snapshot in project dir B), and the original session in dir A continues accumulating turns, a subsequent fork to dir C can pick up the stale snapshot from B instead of the fresh transcript from A. The resumed Claude then silently starts from an earlier point in the conversation with no indication that turns are missing. Sources/ClaudeSessionTranscriptSeeder.swift — the inner project-dir scan loop (lines 87–113) needs to prefer the most recently modified transcript when multiple project dirs contain the same session ID. Important Files Changed
Reviews (3): Last reviewed commit: "Seed into the authoritative config root ..." | Re-trigger Greptile |
| for configDir in configDirCandidates { | ||
| let projects = configDir.appendingPathComponent("projects") | ||
| let targetDir = projects.appendingPathComponent(targetName) | ||
| let targetTranscript = targetDir.appendingPathComponent(transcriptName) | ||
| if fileManager.fileExists(atPath: targetTranscript.path) { | ||
| return true | ||
| } | ||
| guard let projectDirs = try? fileManager.contentsOfDirectory( | ||
| at: projects, includingPropertiesForKeys: nil) else { | ||
| continue | ||
| } | ||
| for projectDir in projectDirs where projectDir.lastPathComponent != targetName { | ||
| let sourceTranscript = projectDir.appendingPathComponent(transcriptName) | ||
| guard isFile(at: sourceTranscript, fileManager: fileManager) else { continue } | ||
| do { | ||
| try fileManager.createDirectory(at: targetDir, withIntermediateDirectories: true) | ||
| try fileManager.copyItem(at: sourceTranscript, to: targetTranscript) | ||
| } catch { | ||
| continue | ||
| } | ||
| let sidecar = projectDir.appendingPathComponent(sessionId) | ||
| let sidecarTarget = targetDir.appendingPathComponent(sessionId) | ||
| if isDirectory(at: sidecar, fileManager: fileManager), | ||
| !fileManager.fileExists(atPath: sidecarTarget.path) { | ||
| try? fileManager.copyItem(at: sidecar, to: sidecarTarget) | ||
| } | ||
| return true | ||
| } | ||
| } |
There was a problem hiding this comment.
Cross-configDir copy: transcript lands in the wrong config dir
seedIfNeeded iterates candidates, finds the source transcript in configDir[i], and copies it within that same dir. But the resumed Claude process reads from configDirCandidates[0] (the most-specific dir). When these two differ, the copy is placed where Claude will never look and the launch still hits "No conversation found."
Concrete failure: original session used the default ~/.claude (no CLAUDE_CONFIG_DIR in launchEnvironment), but the current cmux process has CLAUDE_CONFIG_DIR=/other in its environment. Candidates become ["/other", "~/.claude"]. The outer loop finds no transcript in /other, then finds the source in ~/.claude and copies it to ~/.claude/projects/<targetName>/. The resumed claude inherits the process environment, uses /other, and still fails.
The fix is to copy into configDirCandidates[0] (or the first writable candidate) regardless of which candidate the source was discovered in.
| for configDir in configDirCandidates { | ||
| let projects = configDir.appendingPathComponent("projects") | ||
| let targetDir = projects.appendingPathComponent(targetName) | ||
| let targetTranscript = targetDir.appendingPathComponent(transcriptName) | ||
| if fileManager.fileExists(atPath: targetTranscript.path) { | ||
| return true | ||
| } | ||
| guard let projectDirs = try? fileManager.contentsOfDirectory( | ||
| at: projects, includingPropertiesForKeys: nil) else { | ||
| continue | ||
| } | ||
| for projectDir in projectDirs where projectDir.lastPathComponent != targetName { | ||
| let sourceTranscript = projectDir.appendingPathComponent(transcriptName) | ||
| guard isFile(at: sourceTranscript, fileManager: fileManager) else { continue } | ||
| do { | ||
| try fileManager.createDirectory(at: targetDir, withIntermediateDirectories: true) | ||
| try fileManager.copyItem(at: sourceTranscript, to: targetTranscript) | ||
| } catch { | ||
| continue | ||
| } | ||
| let sidecar = projectDir.appendingPathComponent(sessionId) | ||
| let sidecarTarget = targetDir.appendingPathComponent(sessionId) | ||
| if isDirectory(at: sidecar, fileManager: fileManager), | ||
| !fileManager.fileExists(atPath: sidecarTarget.path) { | ||
| try? fileManager.copyItem(at: sidecar, to: sidecarTarget) | ||
| } | ||
| return true | ||
| } | ||
| } | ||
| return false |
There was a problem hiding this comment.
Synchronous O(N) project-dir scan on interactive path
contentsOfDirectory lists every entry under projects/, then isFile stat-checks each one, all synchronously inside seedIfNeeded. This is called from forkStartupInput and resumeStartupInput, which are invoked on the main-actor path in Workspace.forkAgentConversation (fork-to-tab, fork-to-split, fork-to-new-workspace) and restoreSessionSnapshot. For a user with hundreds of sessions the scan adds visible latency to every fork and restore. Consider moving the seeding call into an async or off-main helper so the I/O doesn't block the UI thread.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Keeps the file inside its length budget: the resume/fork startup-input methods and AgentResumeScriptStore move to RestorableAgentSessionStartupInput.swift unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ede8bd7880
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for configDir in configDirCandidates { | ||
| let projects = configDir.appendingPathComponent("projects") | ||
| let targetDir = projects.appendingPathComponent(targetName) | ||
| let targetTranscript = targetDir.appendingPathComponent(transcriptName) |
There was a problem hiding this comment.
Seed the configured Claude root, not a fallback root
When a snapshot carries CLAUDE_CONFIG_DIR, the resume command will launch Claude with that configured root, but this loop treats every candidate as both a possible source and the destination. If the configured root is empty/stale while the transcript is only found in a later fallback such as ~/.claude or the process env, seedIfNeeded copies into that fallback target and returns true, while the resumed claude --resume still reads the configured root and reports no conversation found. Please keep the active launch config root as the destination and use fallbacks only as sources, or only return success when the transcript is present in the launch root.
Useful? React with 👍 / 👎.
Review fixes (Bugbot high/medium, Greptile P1): the first candidate is now the only root written to, later candidates are source-search fallbacks, and every candidate goes through ClaudeConfigDirectoryPath.preferredPath so seeding targets the same root the launch environment policy hands the resumed claude. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d2677ece3e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for configDir in configDirCandidates { | ||
| let projects = configDir.appendingPathComponent("projects") | ||
| guard let projectDirs = try? fileManager.contentsOfDirectory( | ||
| at: projects, includingPropertiesForKeys: nil) else { | ||
| continue | ||
| } | ||
| for projectDir in projectDirs { | ||
| let sourceTranscript = projectDir.appendingPathComponent(transcriptName) |
There was a problem hiding this comment.
Choose the latest duplicate transcript when seeding
After this copy-based seeding runs once, the same Claude session id can exist in multiple projects/<cwd>/ directories; if the user continues the resumed conversation in the new cwd and later forks/restores it into a third cwd, this loop copies the first matching <session>.jsonl returned by directory enumeration rather than the most recently updated transcript. In that scenario the new pane can resume from a stale copy and miss the latest conversation turns, so the seeder should select the newest/source-authoritative transcript instead of returning on the first match.
Useful? React with 👍 / 👎.
|
Review findings addressed in d2677ec:
ClaudeSessionTranscriptSeederTests (10 tests) verified green on the AWS M4 Pro runner at d2677ec; the ci.yml tests job on GitHub runners only executed a subset of cmuxTests this run, so the suite was validated out-of-band. |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
Sources/ClaudeSessionTranscriptSeeder.swift (1)
16-21: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick winFix inconsistent Claude project-dir encoding between seeding and restore lookup
ClaudeSessionTranscriptSeeder.encodedProjectDirName(forWorkingDirectory:)replaces every non-alphanumeric ASCII character with-(e.g.repo_a.b→repo-a-b).RestorableAgentSessionIndex.encodeClaudeProjectDir(_:)only replaces/and.with-(leaves_), so restore probes a differentprojects/<encoded-cwd>/directory for the same cwd (e.g.repo_a.b→repo_a-b).- Use a single shared encoding helper for both seeding and restore lookup, and add a regression test that verifies restore finds the seeded transcript when the cwd contains
_/other non-alphanumerics.critical_fixes_required
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/ClaudeSessionTranscriptSeeder.swift` around lines 16 - 21, Replace the two differing encoders with a single shared helper and update both ClaudeSessionTranscriptSeeder.encodedProjectDirName(forWorkingDirectory:) and RestorableAgentSessionIndex.encodeClaudeProjectDir(_:) to call it; implement the helper (e.g., encodeClaudeProjectDirName(_:) in a common util/extension) to perform the canonical replacement logic used for project dir names (replace "/" and "." with "-" while preserving "_" and other characters per the intended behavior), update both callers to use the helper, and add a regression test that seeds a transcript then attempts restore with a working directory containing "_" and other non-alphanumerics to assert the seeded transcript is found.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@Sources/ClaudeSessionTranscriptSeeder.swift`:
- Around line 16-21: Replace the two differing encoders with a single shared
helper and update both
ClaudeSessionTranscriptSeeder.encodedProjectDirName(forWorkingDirectory:) and
RestorableAgentSessionIndex.encodeClaudeProjectDir(_:) to call it; implement the
helper (e.g., encodeClaudeProjectDirName(_:) in a common util/extension) to
perform the canonical replacement logic used for project dir names (replace "/"
and "." with "-" while preserving "_" and other characters per the intended
behavior), update both callers to use the helper, and add a regression test that
seeds a transcript then attempts restore with a working directory containing "_"
and other non-alphanumerics to assert the seeded transcript is found.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: d3405aac-1685-489d-9350-f85d6b6b0a97
📒 Files selected for processing (2)
Sources/ClaudeSessionTranscriptSeeder.swiftcmuxTests/ClaudeSessionTranscriptSeederTests.swift

Forking or restoring a Claude conversation into a workspace whose cwd differs from the session's origin failed at launch with
No conversation found with session ID, becauseclaude --resume <id>only searchesprojects/<encoded-cwd>/for the current working directory.ClaudeSessionTranscriptSeedercopies the transcript (and its optional<id>/sidecar dir) into the target cwd's project dir right before launch, inside the config root the resumed claude will actually read (the launch snapshot's capturedCLAUDE_CONFIG_DIRfirst, then the app's env, then~/.claude). It is wired into the shared startup-input funnel inSessionRestorableAgentSnapshot, so fork-to-tab, fork-to-split, fork-to-new-workspace, and session restore all go through one path. Copy rather than hardlink so concurrent resumes of the same id from two cwds cannot interleave appends into one inode. Best-effort: seeding failures never block the launch.Two-commit structure: the first commit adds the failing tests (red), the second adds the fix (green).
Fixes #5941
🤖 Generated with Claude Code
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.Note
Medium Risk
Touches agent resume/fork launch and writes under the user's Claude config tree, but copies are best-effort, validated session IDs, and failures do not block launch.
Overview
Fixes “No conversation found” when resuming or forking a Claude session into a workspace whose cwd differs from where the transcript was originally stored. Claude only looks under
projects/<encoded-cwd>/, so the PR copies<sessionId>.jsonl(and optional sidecar dir) into that folder under the config root the resumed process will read—launch snapshotCLAUDE_CONFIG_DIRfirst, then process env, then~/.claude, with legacy path preference aligned to launch policy.ClaudeSessionTranscriptSeederimplements encoding (realpath-style/private/tmp, non-alphanumerics →-), config candidate discovery, idempotent copy (no hardlinks), UUID-shaped session id validation, and best-effort behavior that never blocks launch.Resume/fork startup paths are centralized in new
RestorableAgentSessionStartupInput.swift, which calls seeding immediately before terminal startup input forresumeStartupInput,resumeStartupCommand, andforkStartupInput. The same startup/script-store logic is removed fromRestorableAgentSession.swift(file-size refactor; launcher quoting now usesTerminalStartupShellQuoting.singleQuoted).Adds
ClaudeSessionTranscriptSeederTestsand Xcode project entries for the new sources.Reviewed by Cursor Bugbot for commit d2677ec. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by cubic
Fixes failed
claude --resume <id>when forking or restoring into a different cwd by seeding the transcript into the target project dir at launch. Eliminates “No conversation found” errors; resume now works across workspaces. Fixes #5941.Bug Fixes
ClaudeSessionTranscriptSeederto copy<id>.jsonland optional<id>/sidecar intoprojects/<encoded-cwd>/in the authoritative config root (first candidate: launch envCLAUDE_CONFIG_DIR, then process env, then~/.claude), with each candidate normalized viaClaudeConfigDirectoryPath.preferredPath. Later candidates are source fallbacks only; we write only to the first./private, replace non-alphanumerics with-); uses copy instead of hardlink; best-effort: failures don’t block launch.Refactors
RestorableAgentSessionStartupInput.swiftto keepRestorableAgentSession.swiftwithin size limits (no behavior change).Written for commit d2677ec. Summary will update on new commits.
Summary by CodeRabbit
New Features
Tests
Chores