Pin Claude auto-resume binding to the launch cwd (#4256)

EN
1mo ago
+665-96

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 + ClaudeProjectDirEncoding in CMUXAgentLaunch (single source of truth for both targets). verifiedWorkingDirectory(...):
    1. matches a candidate dir's encoding to the transcript path's project-dir segment;
    2. else recovers the launch cwd from the transcript file's top-level "cwd", accepted only if it re-encodes to that same project dir;
    3. else probes the Claude config roots on disk.
  • CLI binding builder publishAgentSurfaceResumeBinding resolves the Claude resume cwd through it before the existing namespacing fallback, gated to kind == "claude" (other agents unchanged). transcriptPath is 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

  • CMUXAgentLaunch package: swift test green (added ClaudeResumeWorkingDirectoryTests — 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 published surface.resume.set cwd/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, ClaudeProjectDirEncoding is 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 cwd in 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 nil and falls through safely.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
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 threads transcriptPath; fixes non-optional sessionId handling.
  • Refactors

    • Extracts ClaudeProjectDirEncoding and 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.

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.