Pin Claude auto-resume binding to the launch cwd (#4256)
Summary
Fixes the Claude auto-resume binding path (source: agent-hook) so a restored session cds into the directory Claude filed the transcript under, not a runtime cwd the agent drifted into mid-session. Resolves #4256.
Claude namespaces each transcript under <config>/projects/<encoded launch cwd>/<id>.jsonl, and claude --resume <id> only locates it from that same cwd. When a session starts in one dir and the agent later cds elsewhere (repo root → worktree, or $HOME → a subdir), the persisted binding pinned the drifted cwd, so auto-resume ran cd '<drift>' && claude --resume <id> and failed with No conversation found.
This is the follow-up that #5154 explicitly deferred:
The same root-cause drift also reaches the auto-resume binding path (source: agent-hook, --resume), whose cwd originates in the CLI hook (CLI/cmux.swift) … intentionally left as a focused follow-up.
#5154 fixed the snapshot fork/restore path (Sources/RestorableAgentSession.swift); the binding published by publishAgentSurfaceResumeBinding in CLI/cmux.swift was untouched.
Why a candidate match isn't enough
In the real failure (captured from a live 0.64.9 session, see #4256) both inputs to AgentResumeWorkingDirectory().resolve(...) are the drift by the time the binding is published: the runtime cwd is the drift, and the captured launchCommand.workingDirectory also collapsed to the drift (SessionStart fired after the agent moved, and the hook process had no trusted CMUX_AGENT_LAUNCH_CWD, so agentLaunchCommandFromEnvironment fell back to parsedInput.cwd). The true launch cwd is therefore not among the candidates.
What is reliable: every Claude transcript record carries the launch cwd in a top-level "cwd", and transcript_path is in the hook payload. So the launch cwd is recovered from the transcript content and accepted only when it re-encodes to the transcript's project dir (no lossy decode).
Changes
- New shared type
ClaudeResumeWorkingDirectory+ClaudeProjectDirEncodinginCMUXAgentLaunch(single source of truth for both targets).verifiedWorkingDirectory(...):- matches a candidate dir's encoding to the transcript path's project-dir segment;
- else recovers the launch cwd from the transcript file's top-level
"cwd", accepted only if it re-encodes to that same project dir; - else probes the Claude config roots on disk.
- CLI binding builder
publishAgentSurfaceResumeBindingresolves the Claude resume cwd through it before the existing namespacing fallback, gated tokind == "claude"(other agents unchanged).transcriptPathis threaded through all 8 call sites. - App snapshot resolver delegates to the same shared type, removing the duplicated transcript-verification logic (no behavior change; the existing #5154 tests cover it).
Testing
CMUXAgentLaunchpackage:swift testgreen (addedClaudeResumeWorkingDirectoryTests— candidate match, transcript-content recovery, round-trip guard rejection, config scan, empty inputs; full suite 161 tests pass).- Added a CLI hook integration test (
cmuxTests/CLIGenericHookPersistenceTests.swift) that reproduces the drift (both candidates = drift, transcript under the launch dir) and asserts the publishedsurface.resume.setcwd/command pin the launch dir.
⚠️ Disclosure: I could not run the full Xcode app/CLI build or the cmuxTests target locally (this machine is missing CoreSimulator.framework, so xcodebuild fails to load IDESimulatorFoundation before compiling). The shared-package logic and its unit tests are verified via swift test; the CLI/app integration compiles against the same shared API but its full build + the new integration test should be confirmed by CI.
Residual notes for reviewers
- If Claude ever changes its project-dir encoding,
ClaudeProjectDirEncodingis now the one place to update (used by both app and CLI). - The round-trip guard is intentionally strict: it only accepts a recovered cwd that re-encodes to the transcript's project dir, so a foreign/spoofed
cwdin transcript content can't be honored. - The transcript read is a bounded 64 KB head read with per-line JSON parsing; a mid-codepoint UTF-8 cut yields
niland falls through safely.
🤖 Generated with Claude Code
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.
Summary by cubic
Pins Claude auto-resume to the original launch directory and verifies it against the transcript to prevent “No conversation found” after mid-session cd. Resolves #4256.
-
Bug Fixes
- Verifies the resume cwd with
ClaudeResumeWorkingDirectory: match the transcript’s project dir, else recover from transcript content with a round-trip check, else scan config roots; supports nested<id>/messages/<id>.jsonl. - Updates CLI
publishAgentSurfaceResumeBinding(Claude only) to resolve via transcript before namespacing and threadstranscriptPath; fixes non-optionalsessionIdhandling.
- Verifies the resume cwd with
-
Refactors
- Extracts
ClaudeProjectDirEncodingand centralizes logic in@macOS/CMUXAgentLaunch. - App snapshot resolver now delegates to one shared resolver per index-load pass with memoized config-root/transcript lookups, removing duplicate verification code.
- Extracts
Summary by CodeRabbit
-
New Features
- Improved Claude resume handling by verifying and resolving the correct working directory from the session transcript, even when the current CWD has drifted.
- Resume/session flows now better preserve and propagate transcript path context for more consistent continuation behavior.
-
Bug Fixes
- Fixed resume targeting the wrong folder by validating the expected project-dir encoding and, when needed, recovering the launch CWD from transcript JSONL records.
- Added more reliable fallback behavior (including honoring
CLAUDE_CONFIG_DIR) when transcript lookup isn’t provided.
-
Tests
- Added coverage for transcript-derived CWD recovery and Claude project directory encoding rules.