Skip to content

Fix Claude/agent session restore across cmux relaunches - #3805

Open
aml11 wants to merge 8 commits into
manaflow-ai:mainfrom
aml11:fix-claude-session-restore-panel-id-fallback
Open

aml11 wants to merge 8 commits into
manaflow-ai:mainfrom
aml11:fix-claude-session-restore-panel-id-fallback

Conversation

@aml11

@aml11 aml11 commented May 9, 2026 •

Copy link
Copy Markdown

Summary

Fixes three independent bugs that prevent Claude/Codex/etc. session
auto-resume from working after a cmux relaunch.

  • Commit 1 — index lookup misses across launches.
    RestorableAgentSessionIndex is keyed by (workspaceId, panelId),
    but Workspace.id is regenerated as a fresh UUID() on every cmux
    launch (Sources/Workspace.swift:7682). After the first relaunch, the
    saved hook record's workspaceId no longer matches the live workspace,
    the strict index lookup misses, and autosave persists the panel
    snapshot with agent: nil. From then on agents never auto-resume.
    The fix adds a panel-id-only fallback. Strict (workspaceId, panelId)
    still wins; only fall back to panelId-only when the strict key
    misses, picking the most recent record per panel.

  • Commit 2 — in-lifetime SessionEnd doesn't clear in-memory state.
    After commit 1, restoration reliably populates
    restoredAgentSnapshotsByPanelId[panelId]. When the user /exits
    claude in the same lifetime, claude-hook session-end consumes the
    on-disk record but doesn't clear the in-memory entry, so the next
    save re-embeds the stale agent and the next launch tries to resume a
    session that already exited. The fix adds a new agent_session_ended
    v1 control command and wires claude-hook session-end (plus the
    generic-agent SessionEnd path used by Codex, Gemini, etc.) to call
    it alongside the existing clear_agent_pid / clear_status calls.

  • Commit 3 — auto-resume into "No conversation found" after rename.
    When a user runs /rename <name> immediately after Claude starts,
    before sending any prompt, Claude writes only metadata events
    (custom-title, agent-name, permission-mode) to the transcript.
    claude --resume <id> rejects metadata-only transcripts with
    No conversation found with session ID: …. The fix derives Claude's
    transcript path from the hook record's cwd and sessionId and
    filters out metadata-only records during index load. Fail-open: if
    the transcript is missing or unreadable, the record is kept so
    Claude can produce its own canonical error.

Why

Empirical: on a machine with active Claude sessions,
~/.cmuxterm/claude-hook-sessions.json carried 16 active records, but
~/Library/Application Support/cmux/session-com.cmuxterm.app.json had
zero panels with an agent field. 7 panel UUIDs in the snapshot
matched surfaceId entries in the hook file — the data was there, the
lookup just couldn't connect them across the workspace-UUID
regeneration. Bugs 2 and 3 were observed during the same investigation.

Closes #2941
Refs #3342
Refs #3322

Testing

  • xcodebuild test against the real cmuxTests target (Xcode 26.2,
    macOS 26.x, Apple Silicon). All 3 new tests pass:
    • testRestorableAgentIndexFallsBackToPanelIdAfterWorkspaceUUIDRegenerates
    • testMarkRestorableAgentSessionEndedNoopsWithoutEntry
    • testClaudeTranscriptHasConversationFiltersMetadataOnlyTranscripts
  • Reverting Sources/RestorableAgentSession.swift to pre-fix while
    keeping the test file shows the regression tests fail with
    XCTUnwrap failed: expected non-nil value and an assertion mismatch
    on metadata-only transcripts — i.e. the tests catch the bugs and the
    fixes are causally responsible.
  • Manual repro of bug 1: built a debug app via ./scripts/reload.sh --tag fix-restore --launch, started a Claude session, Cmd+Q'd,
    relaunched. The restored panel auto-typed
    cd '<cwd>' && 'claude' '--resume' '<id>' and Claude reattached.
  • Manual repro of bug 3: ran /rename test in a fresh Claude session
    before any prompt, exited, restarted, observed No conversation found with session ID: <id> from the auto-resume. With the fix, the
    hook record is filtered out during load and the panel restores as a
    fresh shell instead.
  • Swift file length budget check still passes
    (python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv → exit 0).

Local repro command:

CMUX_SKIP_ZIG_BUILD=1 \
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit \
  -configuration Debug -destination 'platform=macOS' \
  -only-testing:cmuxTests/SessionPersistenceTests/testRestorableAgentIndexFallsBackToPanelIdAfterWorkspaceUUIDRegenerates \
  -only-testing:cmuxTests/SessionPersistenceTests/testMarkRestorableAgentSessionEndedNoopsWithoutEntry \
  test

Demo Video

cmux.video.mp4

Checklist

  • I tested the change locally (xcodebuild test + manual relaunch repro)
  • I added or updated tests for behavior changes (3 new regression tests)
  • I updated docs/changelog if needed (no doc changes — implementation detail)
  • I requested bot reviews after my latest commit
  • All code review bot comments are resolved
  • All human review comments are resolved

Summary by CodeRabbit

  • New Features

    • UI can be notified to mark specific restored-agent sessions ended (scoped to panel/surface and session) so panels update immediately.
    • Restoration skips auto-resume when the agent's working directory is missing or invalid.
  • Bug Fixes

    • Metadata-only transcripts are excluded from auto-resume; only sessions with conversation events restore.
    • Restoration falls back to panel-only matching when workspace identifiers change.
  • Tests

    • Added tests for fallback resolution, transcript filtering, deterministic tie-breaking, and safe session-end handling.

Review Change Stack

@vercel

vercel Bot commented May 9, 2026

Copy link
Copy Markdown

@aml11 is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented May 9, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a resume cwd existence guard, dual-index restorable-session lookup with panelId fallback, Claude transcript-based filtering for auto-resume, conditional restoration of embedded agents, Workspace.markRestorableAgentSessionEnded, a new agent_session_ended socket command and CLI emissions, and tests covering fallback, transcript filtering, and session-end marking.

Changes

Restorable Agent Session Lifecycle and Transcript Validation

Layer / File(s) Summary
Resume Command Guard
Sources/RestorableAgentSession.swift
resumeShellCommand(...) now checks that the resolved cwd exists and returns nil if missing.
Data Schema & Lookup
Sources/RestorableAgentSession.swift
RestorableAgentSessionIndex adds snapshotsByPanelId, private initializer accepts both maps, and snapshot(workspaceId:panelId:) falls back to panelId-only; index derivation occurs after load.
Transcript Config
Sources/RestorableAgentSession.swift
Adds claudeConfigDir(in:) to resolve the Claude transcript root (tilde expansion, env override).
Transcript Line Probe
Sources/RestorableAgentSession.swift
Adds conversationEventRegex and lineDeclaresConversationEvent() to detect user/assistant events in Claude JSONL lines without full JSON parsing.
Transcript Validation Helpers
Sources/RestorableAgentSession.swift
Adds claudeTranscriptHasConversation(...) to scan JSONL transcripts in bounded reads and detect conversation events; missing/unreadable transcripts fail-open.
Eligibility Gate
Sources/RestorableAgentSession.swift
Adds claudeAgentIsRestorable(...) which passes non-claude kinds, requires non-empty cwd for Claude, and delegates to transcript scanning.
Filtering During Load
Sources/RestorableAgentSession.swift
load(...) filters Claude hook records via claudeAgentIsRestorable(...) and derives snapshotsByPanelId by selecting the newest snapshot per panelId.
Conditional Restoration
Sources/Workspace.swift
When restoring terminals, .claude agent snapshots are kept only if claudeAgentIsRestorable(...) returns true; otherwise the agent snapshot is dropped.
Session End Marking API
Sources/Workspace.swift
Adds markRestorableAgentSessionEnded(panelId:sessionId:) to invalidate saved fingerprint, remove saved snapshot, and clear pending auto-resume for that panel.
Command Routing
Sources/TerminalController.swift
Adds V1 socket agent_session_ended command and a handler that parses/validates --tab/--surface/--session and schedules markRestorableAgentSessionEnded.
IPC Signal Integration
CLI/cmux.swift
Two cleanup flows now emit agent_session_ended --tab=... --surface=... --session=... when consuming restored sessions with non-empty surfaceId.
Tests
cmuxTests/SessionPersistenceTests.swift
Adds tests for panelId fallback after workspace UUID regeneration, fallback selection of most recent per panelId, deterministic tie-break, Claude transcript filtering behavior, and @MainActor safety of markRestorableAgentSessionEnded when no bookkeeping exists.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • manaflow-ai/cmux#3636: Modifies restorable-agent/session restoration code and tests; closely related.
  • manaflow-ai/cmux#3211: Changes around Claude resume and CLAUDE_CONFIG_DIR handling; related to transcript/config resolution.
  • manaflow-ai/cmux#2978: Introduced earlier restorable-session plumbing that this PR extends; directly related.

Poem

🐰 I hopped through sessions, sniffed the logs,

Skipped the quiet transcripts with no words to share,
I tapped the app and whispered "session-end",
Cleared stale snapshots, let fresh shells prepare.
Hop, hop — clean tabs and tidy air.


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 warning, 2 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Cmux Swift Actor Isolation ❓ Inconclusive No result was produced after verification. Marking as INCONCLUSIVE. Re-run the check or adjust instructions to produce a final result.
Cmux Swift @Concurrent ❓ Inconclusive No result was produced after verification. Marking as INCONCLUSIVE. Re-run the check or adjust instructions to produce a final result.
✅ Passed checks (12 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix Claude/agent session restore across cmux relaunches' clearly and concisely summarizes the primary change: addressing session restoration issues across application restarts.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering all template sections: Summary (why, what changed), Testing (xcodebuild tests, manual repro), and a detailed Checklist with completion status.
Linked Issues check ✅ Passed The PR addresses issue #2941 by implementing a panel-ID-only fallback lookup to handle workspace UUID regeneration across restarts, enabling reliable surface-to-session mapping. Additional fixes handle in-lifetime session-end state clearing and metadata-only transcript filtering.
Out of Scope Changes check ✅ Passed All code changes are directly scoped to fixing the three identified session-restore bugs: index fallback (RestorableAgentSession), session-end cleanup (TerminalController, CLI/cmux.swift), and transcript validation (RestorableAgentSession, Workspace). Supporting test cases validate the fixes.
Cmux Swift Blocking Runtime ✅ Passed PR introduces no new blocking synchronization primitives. Bounded file I/O during panel restoration is launch-time only, approved by reviewers.
Cmux No Hacky Sleeps ✅ Passed All modified files are Swift. The rule explicitly exempts Swift code, delegating it to swift-blocking-runtime.md. This check is not applicable to this PR.
Cmux Swift Concurrency ✅ Passed No legacy async patterns. Uses async/await with proper await, sync helpers, TerminalMutationBus for UI mutations. No DispatchQueue.global, Combine, completion-handlers, or fire-and-forget Tasks.
Cmux Swift File And Package Boundaries ✅ Passed All files within budgets. No >250 line additions to oversized files. Focused responsibilities in appropriate locations.
Cmux Swift Logging ✅ Passed Logging complies with swift-logging.md. CLI print() is allowed. New methods have no prohibited logging. Workspace uses #if DEBUG-guarded log with redacted data. No secrets exposed.
Cmux Swiftui State Layout ✅ Passed PR introduces no new SwiftUI state patterns. Changes are to model logic, CLI handlers, and test utilities with no @Published/@Observable/@StateObject additions or render-time mutations detected.
Cmux Architecture Rethink ✅ Passed Correctness fixes with clear ownership (@MainActor TerminalController). No timing repairs, new state, or split lifecycle. SessionId-scoping invariant documented.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR does not add or materially change NSWindow, NSPanel, NSWindowController, SwiftUI Window, or WindowGroup. Changes are limited to agent session restoration and state management logic.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented May 9, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes three independent bugs that prevent Claude/Codex/Gemini session auto-resume from working after a cmux relaunch. The fixes are well-isolated with regression tests for all three failure modes.

  • Bug 1 (index fallback): RestorableAgentSessionIndex gains a snapshotsByPanelId fallback dict so that workspace-UUID regeneration across launches no longer silently breaks the (workspaceId, panelId) lookup. The fold is correctly sorted before building the dict so tie-breaks are deterministic.
  • Bug 2 (in-lifetime session end): A new agent_session_ended v1 control command clears restoredAgentSnapshotsByPanelId in-memory when Claude or a generic agent exits in the same launch, preventing the stale snapshot from being re-embedded in the next autosave. The sessionId guard prevents a late hook for session A from wiping a freshly started session B in the same panel.
  • Bug 3 (metadata-only transcript filter): claudeTranscriptHasConversation / claudeAgentIsRestorable scan the JSONL transcript and drop records whose transcripts contain only metadata events, avoiding the No conversation found error from claude --resume. Both the index-load path and the Workspace.createPanel path route through the shared helper.

Confidence Score: 5/5

Safe to merge — all three session-restore regressions are addressed with correct logic and regression tests, and no new data-loss or crash paths were introduced.

The panelId fallback sort, the sessionId-scoped in-memory clear, the fail-open transcript filter, and the cwd directory guard are all correctly implemented and independently tested. The only open items are tracked follow-ups acknowledged in prior review threads, not new regressions introduced here.

Sources/Workspace.swift — the synchronous transcript scan in createPanel on the main actor is an open follow-up from prior review threads, not introduced by this PR but still unresolved.

Important Files Changed

Filename Overview
Sources/RestorableAgentSession.swift Core fix file — adds panelId fallback index, deterministic tie-break sort, transcript scan helper, and cwd directory-existence guard. 591→787 lines (under 800 threshold).
Sources/Workspace.swift Adds markRestorableAgentSessionEnded and routes createPanel through claudeAgentIsRestorable. The synchronous transcript scan inside createPanel on @mainactor is an open issue already tracked in review threads.
Sources/TerminalController.swift Adds agentSessionEnded v1 command handler with proper UUID and sessionId validation, dispatches via scheduleSidebarMutation. 17,452→17,486 lines (+34 actual); budget bump corrects pre-existing overage.
CLI/cmux.swift Adds agent_session_ended calls after session-end hook consumption in both the claude-hook and generic-agent paths (+23 actual lines). Budget corrected from 20,530 to 20,970 to cover pre-existing ~390-line overage.
cmuxTests/SessionPersistenceTests.swift Adds 5 new regression tests covering panelId fallback, recency ordering, tie-break determinism, transcript filtering, missing-cwd guard, and the safe no-op for session-end without a prior snapshot.
.github/swift-file-length-budget.tsv Bumps CLI/cmux.swift budget by +440 and TerminalController.swift by +183. Both files were already over their old budgets before this PR; the increase reconciles pre-existing debt plus a cushion.

Sequence Diagram

sequenceDiagram
    participant CLI as cmux CLI (claude-hook)
    participant TC as TerminalController
    participant WS as Workspace
    participant Index as RestorableAgentSessionIndex
    participant FS as Filesystem

    Note over Index,FS: Bug 1 - Index load (cross-launch)
    Index->>FS: Load hook-sessions.json
    Index->>Index: Build snapshotsByPanel[(workspaceId,panelId)]
    Index->>FS: claudeAgentIsRestorable - check transcript
    Index->>Index: Build snapshotsByPanelId[panelId] fallback (sorted by updatedAt desc)
    WS->>Index: snapshot(workspaceId: NEW_UUID, panelId: P)
    Index-->>WS: strict miss - panelId fallback returns snapshot

    Note over CLI,WS: Bug 2 - In-lifetime session end
    CLI->>TC: "agent_session_ended --tab=W --surface=P --session=S"
    TC->>WS: markRestorableAgentSessionEnded(panelId, sessionId)
    WS->>WS: "guard restored.sessionId == S"
    WS->>WS: remove from restoredAgentSnapshotsByPanelId

    Note over WS,FS: Bug 3 - Transcript filter
    WS->>FS: claudeAgentIsRestorable(kind:.claude, sessionId, cwd)
    FS-->>WS: missing transcript - true (fail-open)
    FS-->>WS: metadata-only - false (drop)
    FS-->>WS: has user/assistant - true (keep)
Loading

Reviews (7): Last reviewed commit: "Address review: require directory for cw..." | Re-trigger Greptile

Comment thread Sources/Workspace.swift Outdated
Comment on lines +739 to +744
let hasConversation = RestorableAgentSessionIndex.claudeTranscriptHasConversation(
cwd: cwd,
sessionId: candidate.sessionId,
claudeConfigDir: configDir
)
return hasConversation ? candidate : nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Synchronous file I/O on @MainActor during session restore

claudeTranscriptHasConversation reads up to 1 MB of JSONL data in a synchronous loop using FileHandle.read(upToCount:), called here from createPanel(from:inPane:) which runs entirely on the main actor. restoreSessionSnapshot iterates every panel at launch, so this blocks the main thread once per Claude panel in the saved session. The equivalent call in RestorableAgentSessionIndex.loadIncludingProcessDetectedSnapshots correctly wraps the same work in Task.detached(priority: .utility) — the createPanel call site has no such boundary.

Comment on lines +692 to +694
return true
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 >= vs > asymmetry between the two de-dup passes

The primary resolved dict keeps the most-recent record per (workspaceId, panelId) using a strict > comparison (skip if existing timestamp is strictly newer). The new byPanelId pass uses >= (skip if existing timestamp is equal or newer). When two hook records share the same panelId and identical updatedAt timestamps, the two dicts can resolve to different snapshots depending on iteration order. Consider using > consistently, or explicitly documenting why equal timestamps should prefer the first-seen entry for the panelId fallback index.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real point — the asymmetry is unintentional but harmless in practice.

Ties on updatedAt (a TimeInterval set at hook-write time) only happen when two records share an exact-second timestamp, which is essentially limited to stale duplicate hook records left over from older versions. >= and > differ only in which duplicate wins; either choice is nondeterministic across runs anyway because Swift Dictionary iteration order isn't stable.

Consistency is cheap though — I'll change line 694 to > in a follow-up commit on this branch so both passes use the same comparator.

Comment thread CLI/cmux.swift
Comment on lines +14319 to 14329
// panel so the next session save does not re-embed it. Without
// this, the next cmux launch tries to resume a session that
// was already ended in this lifetime.
if !surfaceId.isEmpty {
_ = try? sendV1Command(
"agent_session_ended --tab=\(workspaceId) --surface=\(surfaceId)",
client: client
)
}
}
print("OK")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 --tab lookup uses the hook record's workspace UUID, which may not match any live workspace

consumedSession.workspaceId is the workspace UUID that was active when the hook record was written. If cmux relaunches before Claude exits, the live workspace will have a regenerated UUID and parseSidebarMutationTabTarget will fail to find the correct workspace, silently dropping the agent_session_ended notification. The in-lifetime case (Bug 2 scenario) works correctly, but the cross-launch case is left uncleared. Adding panel-UUID routing support to the mutation bus would close this gap, consistent with the panelId fallback added to the index for Bug 1.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — the new V1 command routes by workspace UUID, which is stale post-relaunch, so the cross-launch SessionEnd cleanup falls through to a no-op. Same root cause as Bug 1: Workspace.id regenerates.

This PR's Bug 2 fix was scoped to the in-lifetime case (user /exits claude in the same session as the SessionStart) — the doc is explicit about that. There is a partial backstop for the cross-launch case: claude-hook session-end still consumes the on-disk hook record, so the next launch's RestorableAgentSessionIndex won't re-resolve the agent via either the strict key or the panel-id fallback. But you're right that the in-memory restoredAgentSnapshotsByPanelId entry stays until something else clears it.

Tracking as a follow-up: add panel-UUID routing to the mutation bus so agent_session_ended can address by panel-id when the workspace lookup misses, mirroring the panel-id fallback added to the index for Bug 1. Out of scope for this PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@Sources/Workspace.swift`:
- Around line 724-745: Extract the Claude transcript eligibility logic into a
single shared helper (e.g., add a function on RestorableAgentSessionIndex like
claudeTranscriptIsRestorable(cwd: String, sessionId: String, environment:
[String: String]?) or a similarly named static helper), then replace the inline
predicate in Workspace.swift (the closure that computes restorableAgent) and the
existing metadata-only filter in RestorableAgentSession.swift to call that
helper; ensure the helper encapsulates the same checks (agent kind, non-empty
cwd via candidate.workingDirectory or candidate.launchCommand?.workingDirectory,
computing claudeConfigDir from environment, and calling
claudeTranscriptHasConversation) so both fresh-load and restore paths use the
identical eligibility logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a42915bb-2234-4804-9bd2-3093caf24922

📥 Commits

Reviewing files that changed from the base of the PR and between 0e4277f and 7520a44.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (5)
  • CLI/cmux.swift
  • Sources/RestorableAgentSession.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift

Comment thread Sources/Workspace.swift
@aml11

aml11 commented May 9, 2026

Copy link
Copy Markdown
Author
cmux.video.mp4

@aml11
aml11 force-pushed the fix-claude-session-restore-panel-id-fallback branch 2 times, most recently from 34a4416 to d2de220 Compare May 9, 2026 20:28

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 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.

Inline comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 664-700: Extend
testRestorableAgentIndexFallsBackToPanelIdAfterWorkspaceUUIDRegenerates by
adding at least two restorable agent records created with the same panelId but
different timestamps/sessionId values (use makeRestorableAgentIndex or helper
that inserts multiple records for the same panelId, e.g., sessions "old-session"
and "new-session"); after verifying the strict (workspaceId, panelId) match
still returns the exact record, simulate the regenerated workspaceId and call
index.snapshot(workspaceId: regeneratedWorkspaceId, panelId: panelId) and assert
it returns the most-recent/newer record (e.g., "new-session") and the correct
kind, and also keep the existing assertion that an unrelated panelId returns
nil. Ensure you insert records so their timestamps or insertion order reflect
recency so the fallback selection logic is exercised.

In `@Sources/RestorableAgentSession.swift`:
- Around line 728-740: The cheap probe in lineDeclaresConversationEvent assumes
fixed spacing and misses valid JSON like `"type" : "assistant"`; change the
check to match arbitrary whitespace around the colon and allow both "user" and
"assistant" by using a regex such as "\"type\"\\s*:\\s*\"(user|assistant)\""
(precompile an NSRegularExpression for performance) and test the UTF-8 string
against that regex instead of the four fixed contains checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ae24e041-5f28-4a39-af0d-06ff2d875130

📥 Commits

Reviewing files that changed from the base of the PR and between 7520a44 and d2d6cad.

📒 Files selected for processing (3)
  • Sources/RestorableAgentSession.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift

Comment thread cmuxTests/SessionPersistenceTests.swift
Comment thread Sources/RestorableAgentSession.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
cmuxTests/SessionPersistenceTests.swift (1)

664-700: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add coverage for "most-recent record per panel" fallback selection.

Line 664–Line 700 validates fallback for a single record, but it does not verify the recency rule when multiple hook records share the same panelId. Please add a case with at least two records for the same panel and assert fallback returns the newest one after strict (workspaceId, panelId) miss.

🤖 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 `@cmuxTests/SessionPersistenceTests.swift` around lines 664 - 700, Extend the
testRestorableAgentIndexFallsBackToPanelIdAfterWorkspaceUUIDRegenerates case to
add at least two hook records that share the same panelId but different
creation/updated times (use makeRestorableAgentIndex or the helper that builds
the index to insert multiple entries for the same panelId with different
sessionId/kind and a later timestamp for the newest record), then after forcing
the strict (workspaceId, panelId) miss (use a regeneratedWorkspaceId) call
index.snapshot(workspaceId:regeneratedWorkspaceId, panelId:panelId) and assert
it returns the sessionId/kind from the most recent record; keep the existing
assertions (strict match and unknown-panel nil) and only add the multi-record
setup and the recency assertion using the same index and snapshot methods.
Sources/RestorableAgentSession.swift (1)

728-740: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle arbitrary whitespace in the transcript type probe.

lineDeclaresConversationEvent still only matches four fixed substrings, so valid JSONL like "type" : "assistant" is treated as metadata-only and the Claude session gets dropped from restore. This was already flagged in an earlier review and still appears unresolved.

Suggested fix
+    private static let conversationEventPattern = try! NSRegularExpression(
+        pattern: #""type"\s*:\s*"(user|assistant)""#
+    )
+
     private static func lineDeclaresConversationEvent(_ line: Data) -> Bool {
-        // Cheap probe: a line that mentions `"type":"user"` or
-        // `"type":"assistant"` is a real conversation event. Metadata-only
-        // lines (`custom-title`, `agent-name`, `permission-mode`, etc.) don't
-        // match. We avoid full JSON parsing per line for hot-path performance.
         guard !line.isEmpty,
               let text = String(data: line, encoding: .utf8) else {
             return false
         }
-        return text.contains("\"type\":\"user\"")
-            || text.contains("\"type\":\"assistant\"")
-            || text.contains("\"type\": \"user\"")
-            || text.contains("\"type\": \"assistant\"")
+        let range = NSRange(text.startIndex..<text.endIndex, in: text)
+        return conversationEventPattern.firstMatch(in: text, range: range) != nil
     }
🤖 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/RestorableAgentSession.swift` around lines 728 - 740,
lineDeclaresConversationEvent currently only checks four exact substrings and
misses valid JSONL with arbitrary whitespace (e.g. `"type" : "assistant"`);
update lineDeclaresConversationEvent to match `"type"` with optional whitespace
around the colon and either "user" or "assistant" by using a single precompiled
regular expression (e.g. pattern like "\"type\"\\s*:\\s*\"(user|assistant)\"")
or an equivalent fast character-scan, so the probe accepts any amount of
whitespace while preserving the hot-path performance (compile the
NSRegularExpression once and reuse it inside lineDeclaresConversationEvent).
🤖 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.

Inline comments:
In `@Sources/Workspace.swift`:
- Around line 8578-8595: The markRestorableAgentSessionEnded function currently
clears whatever snapshot is stored for a panelId, causing races when a
late/duplicate end event for session A wipes a newer session B that reused the
panel; change markRestorableAgentSessionEnded to accept the ended session
identity (e.g., sessionId or snapshot fingerprint), look up restored =
restoredAgentSnapshotsByPanelId[panelId], compute the current fingerprint via
TabManager.restorableAgentSnapshotFingerprint(restored) (or compare
restored.sessionId), and only set
invalidatedRestoredAgentFingerprintsByPanelId[panelId], removeValue(forKey:),
and remove from restoredAgentAutoResumePendingPanelIds if the provided ended
identity matches the current restored snapshot; update all agent_session_ended
call sites to pass the ended session identity so invalidation becomes ordered
and idempotent.

---

Duplicate comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 664-700: Extend the
testRestorableAgentIndexFallsBackToPanelIdAfterWorkspaceUUIDRegenerates case to
add at least two hook records that share the same panelId but different
creation/updated times (use makeRestorableAgentIndex or the helper that builds
the index to insert multiple entries for the same panelId with different
sessionId/kind and a later timestamp for the newest record), then after forcing
the strict (workspaceId, panelId) miss (use a regeneratedWorkspaceId) call
index.snapshot(workspaceId:regeneratedWorkspaceId, panelId:panelId) and assert
it returns the sessionId/kind from the most recent record; keep the existing
assertions (strict match and unknown-panel nil) and only add the multi-record
setup and the recency assertion using the same index and snapshot methods.

In `@Sources/RestorableAgentSession.swift`:
- Around line 728-740: lineDeclaresConversationEvent currently only checks four
exact substrings and misses valid JSONL with arbitrary whitespace (e.g. `"type"
: "assistant"`); update lineDeclaresConversationEvent to match `"type"` with
optional whitespace around the colon and either "user" or "assistant" by using a
single precompiled regular expression (e.g. pattern like
"\"type\"\\s*:\\s*\"(user|assistant)\"") or an equivalent fast character-scan,
so the probe accepts any amount of whitespace while preserving the hot-path
performance (compile the NSRegularExpression once and reuse it inside
lineDeclaresConversationEvent).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f47643e0-70ad-4852-8cdc-3c88b10c3577

📥 Commits

Reviewing files that changed from the base of the PR and between d2d6cad and d2de220.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (5)
  • CLI/cmux.swift
  • Sources/RestorableAgentSession.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift

Comment thread Sources/Workspace.swift
@aml11
aml11 force-pushed the fix-claude-session-restore-panel-id-fallback branch from d2de220 to 74c955d Compare May 9, 2026 20:44
@aml11

aml11 commented May 9, 2026

Copy link
Copy Markdown
Author

Pushed 74c955d8 to address review feedback:

  • New RestorableAgentSessionIndex.claudeAgentIsRestorable(kind:sessionId:cwd:environment:fileManager:) helper is now the single eligibility check used by both the fresh-load path (RestorableAgentSession.swift) and the snapshot restore path (Workspace.createPanel). Resolves CodeRabbit's duplicated-logic note at Sources/Workspace.swift:745.
  • Flipped the third de-dup pass from >= to > so all three byPanelId comparators are consistent (Greptile's codex-hook: completion messages with negated failure wording are marked as Error #694 thread).
  • Extended testClaudeTranscriptHasConversationFiltersMetadataOnlyTranscripts with four helper-routed assertions (kind-gate, metadata-only filter, real-transcript pass).

Greptile's P1 finding on main-thread sync I/O in createPanel is intentionally deferred to a follow-up — the synchronous restore chain (AppDelegate → TabManager.restoreSessionSnapshot → Workspace.restoreSessionSnapshot → createPanel) would need to be async-ified to wrap the I/O in Task.detached. Empirical bound is small (transcripts with content return on the first user/assistant event; metadata-only ones hit EOF in <10 KB), so deferring is a deliberate scope call. Same applies to the --tab cross-launch SessionEnd cleanup gap on CLI/cmux.swift:14329 — both belong in a separate panel-UUID-routing PR.

Verified locally: xcodebuild test against the three named tests is green, plus a manual relaunch repro (Cmd+Q a Claude panel, relaunch, confirm claude --resume <id> reattaches).

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
Sources/RestorableAgentSession.swift (1)

728-741: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Whitespace variations in JSON type probe still drop valid transcripts.

The contains(...) checks still cover only four fixed shapes ("type":"user", "type":"assistant", and the single-space variants). A pretty-printed transcript line like "type" : "assistant" (or any other whitespace around the colon) is classified as metadata-only and the hook record is dropped at line 578, defeating the auto-resume the rest of this PR is trying to fix. The blast radius is limited because Claude's CLI normally emits compact JSON, but a single regex-based probe removes the assumption entirely:

Suggested fix
+    private static let conversationEventPattern: NSRegularExpression? = try? NSRegularExpression(
+        pattern: #""type"\s*:\s*"(user|assistant)""#
+    )
+
     private static func lineDeclaresConversationEvent(_ line: Data) -> Bool {
         guard !line.isEmpty,
               let text = String(data: line, encoding: .utf8) else {
             return false
         }
-        return text.contains("\"type\":\"user\"")
-            || text.contains("\"type\":\"assistant\"")
-            || text.contains("\"type\": \"user\"")
-            || text.contains("\"type\": \"assistant\"")
+        guard let pattern = conversationEventPattern else {
+            return text.contains("\"type\":\"user\"")
+                || text.contains("\"type\":\"assistant\"")
+        }
+        let range = NSRange(text.startIndex..<text.endIndex, in: text)
+        return pattern.firstMatch(in: text, range: range) != nil
     }
🤖 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/RestorableAgentSession.swift` around lines 728 - 741, The probe in
lineDeclaresConversationEvent only matches four fixed whitespace variants and
misses other valid JSON spacing (e.g. `"type" : "assistant"`), so replace the
multiple contains(...) checks with a single robust check using a regex that
allows optional whitespace around the colon and matches either "user" or
"assistant" (for example pattern like "\"type\"\\s*:\\s*\"(user|assistant)\""),
or alternately do a cheap JSON decode of the small line to inspect the "type"
key; update lineDeclaresConversationEvent to use that regex/JSON check so all
whitespace variations around the colon are accepted.
🤖 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.

Duplicate comments:
In `@Sources/RestorableAgentSession.swift`:
- Around line 728-741: The probe in lineDeclaresConversationEvent only matches
four fixed whitespace variants and misses other valid JSON spacing (e.g. `"type"
: "assistant"`), so replace the multiple contains(...) checks with a single
robust check using a regex that allows optional whitespace around the colon and
matches either "user" or "assistant" (for example pattern like
"\"type\"\\s*:\\s*\"(user|assistant)\""), or alternately do a cheap JSON decode
of the small line to inspect the "type" key; update
lineDeclaresConversationEvent to use that regex/JSON check so all whitespace
variations around the colon are accepted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0d304024-89ef-4f26-a7dd-eb7760aceb42

📥 Commits

Reviewing files that changed from the base of the PR and between d2de220 and 74c955d.

📒 Files selected for processing (3)
  • Sources/RestorableAgentSession.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift

@aml11

aml11 commented May 10, 2026

Copy link
Copy Markdown
Author

Two follow-up commits since my last status update:

  • c94d0d20 Address review: scope SessionEnd to sessionId, JSON whitespace, fallback recency test

    • Resolves CodeRabbit's markRestorableAgentSessionEnded panel-reuse race (Workspace.swift:8595) — markRestorableAgentSessionEnded now takes (panelId:, sessionId:) and no-ops when the snapshot's sessionId doesn't match. V1 command grew a required --session=<id>; both call sites in CLI/cmux.swift propagate it from the consumed hook record.
    • Resolves CodeRabbit's JSON-whitespace probe note (RestorableAgentSession.swift:740) — lineDeclaresConversationEvent now uses a precompiled NSRegularExpression for "type"\s*:\s*"(?:user|assistant)". Tolerates "type" : "user". Test fixture extended with a whitespace-variant transcript.
    • Resolves CodeRabbit's "most-recent record per panel" coverage gap (SessionPersistenceTests.swift:700) — new testRestorableAgentIndexFallbackPicksMostRecentRecordPerPanelId writes two hook records sharing a panelId with different updatedAts and asserts the panel-id fallback returns the newer one after a workspace UUID regenerates.
  • e4ae52e6 Skip auto-resume when the captured working directory is gone (not reviewer-driven; related correctness)

    • When the user deletes the project dir between sessions, the typed-in cd '<cwd>' && '<agent>' --resume <id> would fail at cd and && would short-circuit, leaving the panel at a dead prompt. AgentResumeCommandBuilder.resumeShellCommand now returns nil when the captured cwd is missing on disk; the existing nil-resume path in Workspace.createPanel opens the panel as a fresh shell instead. Adds testResumeCommandIsNilWhenWorkingDirectoryDoesNotExist.

All 5 named tests pass via xcodebuild test (cmux-unit scheme) on Apple Silicon, plus a manual relaunch repro. File-length budgets bumped for the four touched files; TSV stays sorted.

@coderabbitai @greptile-apps please re-evaluate.

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown

@aml11, thanks for the detailed follow-up! Here's a quick summary of what I see addressed before I kick off the full re-review:

c94d0d20

  • ✅ markRestorableAgentSessionEnded panel-reuse race — scoped to (panelId:, sessionId:) with a no-op guard when sessionId doesn't match; --session propagated through the V1 command and both CLI call sites.
  • ✅ JSON-whitespace probe — lineDeclaresConversationEvent replaced with a precompiled NSRegularExpression tolerating arbitrary whitespace around the colon.
  • ✅ Most-recent-record coverage — testRestorableAgentIndexFallbackPicksMostRecentRecordPerPanelId added with two hook records sharing a panelId and differing updatedAts.

e4ae52e6

  • ✅ Deleted-cwd guard — AgentResumeCommandBuilder.resumeShellCommand returns nil when the captured working directory no longer exists on disk, letting createPanel fall back to a fresh shell cleanly.
  • ✅ Regression test testResumeCommandIsNilWhenWorkingDirectoryDoesNotExist added.

Re-triggering a full review now to evaluate the new commits end-to-end.

✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 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.

Inline comments:
In `@Sources/RestorableAgentSession.swift`:
- Around line 610-616: The fold into byPanelId from resolved should use a
deterministic tie-breaker when updatedAt are equal to avoid non-deterministic
overwrites; change the logic in the loop that currently checks
existing.updatedAt > value.updatedAt so that when timestamps are equal it
compares a stable identifier (e.g. existing.snapshot.sessionId or
existing.snapshot.workspaceId) in a deterministic order (lexicographic or UUID
comparison) and only keep the incoming value if it wins that tie-breaker (or
alternatively sort resolved by (panelId, updatedAt, sessionId) before folding).
Update the comparison around byPanelId, resolved,
SessionRestorableAgentSnapshot, key.panelId and value.updatedAt to apply this
secondary comparison.
- Around line 52-60: The guard that checks
FileManager.default.fileExists(atPath: cwd) should require cwd to be a
directory, not just any existing path; change the check in
RestorableAgentSession (the block that builds shellCommand and calls
shellSingleQuoted(cwd)) to use
FileManager.default.fileExists(atPath:isDirectory:) (or an equivalent
isDirectory check) and only proceed when isDirectory is true, otherwise return
nil so resumeStartupInput skips auto-resume.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6570c4cb-37b9-4178-a604-003da5825632

📥 Commits

Reviewing files that changed from the base of the PR and between 0e4277f and e4ae52e.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (5)
  • CLI/cmux.swift
  • Sources/RestorableAgentSession.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift

Comment thread Sources/RestorableAgentSession.swift
Comment thread Sources/RestorableAgentSession.swift Outdated
aml11 added 7 commits May 10, 2026 22:44
`RestorableAgentSessionIndex` is keyed by (workspaceId, panelId), but
`Workspace.id` is regenerated as a fresh `UUID()` on every cmux launch.
After the first relaunch, the saved hook record's `workspaceId` no longer
matches the live workspace, the index lookup misses, and autosave writes
the panel snapshot with `agent: nil`. From then on the user's Claude (and
other agent) sessions never auto-resume, even though
`~/.cmuxterm/<agent>-hook-sessions.json` still has the right session ID
keyed by the panel's surface UUID.

Add a panelId-only fallback lookup to `RestorableAgentSessionIndex`. The
strict `(workspaceId, panelId)` key wins when both match (same cmux
lifetime); otherwise we fall back to the most recent record for the same
panel UUID. Panel UUIDs are the `CMUX_SURFACE_ID` written by hooks and
are unique enough across the hook records to make this safe.

Also add a regression test `testRestorableAgentIndexFallsBackToPanelIdAfterWorkspaceUUIDRegenerates`
that constructs an index for one workspace UUID and verifies the lookup
still resolves under a different (regenerated) workspace UUID, while
unknown panel IDs still miss.
When a panel is created from a restored snapshot,
`Workspace.restoredAgentSnapshotsByPanelId[panelId]` is populated from
`SessionPanelSnapshot.terminal.agent` so the next snapshot save can
re-embed it. After the panel-id fallback fix, this path now succeeds
reliably across launches.

But when the agent later exits in the same lifetime (e.g. user types
`/exit` in claude), only the on-disk hook record gets consumed by the
SessionEnd hook. The in-memory entry in `restoredAgentSnapshotsByPanelId`
is never cleared, so the very next snapshot save re-embeds the stale
agent and the next cmux launch tries to resume a session that has
already finished. (`Workspace.updatePanelShellActivityState` invalidates
on `.commandRunning`, but only fires if the user types another command
after the agent exits — when they just close the window, it never runs.)

Add a `Workspace.markRestorableAgentSessionEnded(panelId:)` method that
clears the in-memory restored-agent state, expose it via a new
`agent_session_ended` v1 control command, and have `claude-hook
session-end` (and the generic agent SessionEnd path used by Codex,
Gemini, etc.) send that command alongside the existing `clear_agent_pid`
/ `clear_status` calls. The result is that when an agent exits and
SessionEnd fires, the panel's restored-agent state is cleared in memory
the same way it gets consumed on disk.

Adds a regression test
`testMarkRestorableAgentSessionEndedNoopsWithoutEntry` that verifies the
helper tolerates being called for panels that never had a restored
snapshot — the v1 command is invoked from a sidebar mutation closure
that targets any panel whose hook fires, including fresh sessions.
Claude rejects `--resume <id>` for sessions whose transcript only contains
metadata events (`custom-title`, `agent-name`, `permission-mode`) with
"No conversation found with session ID …". This happens when a user runs
`/rename` immediately after Claude starts, before sending any user
message — Claude's SessionStart hook fires and cmux records the session,
but the transcript never gets a real exchange because the user only
typed `/rename`. On relaunch cmux auto-resumes into the error.

When loading hook records, derive Claude's project directory path
(`<CLAUDE_CONFIG_DIR>/projects/<cwd-with-/-replaced-by-->/<sessionId>.jsonl`)
and skip records whose transcript has no `"type":"user"` or
`"type":"assistant"` event. Fail open — if the transcript is missing or
unreadable, keep the record and let Claude itself produce the canonical
error at resume time. This is Claude-specific and gated on
`kind == .claude`; other agents are untouched.

Important: do NOT call `NSString.standardizingPath` on the cwd. macOS
collapses `/private/tmp/...` → `/tmp/...`, but Claude stores the project
under the un-resolved form (`-private-tmp-aaa`), so a standardized cwd
would point at the wrong project directory.

Adds `testClaudeTranscriptHasConversationFiltersMetadataOnlyTranscripts`
that builds a fake `~/.claude` layout under a temp dir, writes both a
metadata-only transcript and a real one, and verifies the helper drops
the former, keeps the latter, and passes through unknown transcripts.
The fresh-load path in `RestorableAgentSession.swift` and the snapshot
restore path in `Workspace.createPanel(from:inPane:)` had drifted into
two near-identical metadata-only-transcript filters. Each independently
checked `kind == .claude`, extracted a cwd, resolved the Claude config
dir, and called `claudeTranscriptHasConversation`. The next change to
Claude's transcript rules would have had to land in two places.

Extract `RestorableAgentSessionIndex.claudeAgentIsRestorable(kind:
sessionId:cwd:environment:fileManager:)` as the single source of truth.
Both surfaces now route through it; non-Claude agents pass through
unchanged, missing/empty cwd still fails open.

Also flip the byPanelId fold's tie-break comparator from `>=` to `>`
on the third pass (line 604) so all three passes use the same `>`
comparator. With `Dictionary` iteration order being unspecified across
runs, ties on `updatedAt` (only possible with stale duplicate hook
records sharing an exact-second timestamp) were already nondeterministic
either way; consistency is just easier to reason about.

The existing
`testClaudeTranscriptHasConversationFiltersMetadataOnlyTranscripts`
test gains four assertions that exercise the new helper with the same
temp-dir setup, covering the kind-gate (non-Claude passes), the
metadata-only filter, and a real-transcript pass.

Defers greptile's P1 main-thread-I/O finding to a follow-up: the
synchronous `Workspace.createPanel` path can't `await` Task.detached
without async-ifying the entire restore chain
(`AppDelegate` → `TabManager.restoreSessionSnapshot` →
`Workspace.restoreSessionSnapshot` → `createPanel`). The empirical
cost is bounded — transcripts with content return on the first
`user`/`assistant` event, metadata-only transcripts hit EOF in <10 KB
— but doing it properly belongs in a separate refactor.
…ack recency test

Three changes in response to coderabbitai's review on the latest push:

1. Scope `agent_session_ended` to the ended sessionId so a late/duplicate
   SessionEnd hook for session A can't wipe a freshly-started session B
   that has reused the same panelId. `Workspace.markRestorableAgentSessionEnded`
   now takes `(panelId:, sessionId:)` and no-ops unless the currently
   restored snapshot's sessionId matches. The V1 command grew a required
   `--session=<id>` arg; both call sites in `CLI/cmux.swift` (claude-hook
   session-end and the generic-agent SessionEnd path) propagate it from
   the consumed hook record.

2. Fix `lineDeclaresConversationEvent` to tolerate JSON whitespace around
   the colon. JSON permits `"type" : "user"` (spaces before/after `:`),
   which the four-substring contains check would have misclassified as
   metadata-only. Replaces the substring scan with a precompiled
   NSRegularExpression (`"type"\s*:\s*"(?:user|assistant)"`) that costs
   <1 µs per line and stays correct for valid variations.

3. Add `testRestorableAgentIndexFallbackPicksMostRecentRecordPerPanelId`
   covering the recency rule for the panel-id fallback fold: two hook
   records sharing a panelId with different `updatedAt` timestamps must
   resolve to the newer record after a workspace UUID regenerates.
   Existing test only inserted one record so the recency assertion was
   implicit.

Also extends the existing transcript test with a fourth fixture using
the JSON whitespace variant to lock in manaflow-ai#2 against regression, and bumps
file-length budgets for the four touched files (TSV stays sorted).
cmux's auto-resume types `cd '<cwd>' && '<agent>' --resume <id>` into a
fresh shell. When the user deletes the project dir between sessions, the
`cd` fails, `&&` short-circuits before the agent runs, and the panel
sits at a dead "no such file or directory" prompt with no way back.
Ghostty's child silently falls back to the parent cwd when its
`working_directory` is missing (`ghostty/src/Command.zig:209`), so the
shell still starts — but the `cd` typed via `initial_input` is
text-level and can't share that fallback.

Return nil from `AgentResumeCommandBuilder.resumeShellCommand` when the
captured cwd no longer exists on disk. The caller chain
(`SessionRestorableAgentSnapshot.resumeStartupInput` →
`Workspace.createPanel`'s `restoredAgentResumeInput`) already treats a
nil resume command as "skip auto-resume", so the panel opens to a
fresh shell instead of the dead prompt. User can still
`claude --resume <id>` manually if they want the conversation back.

Adds `testResumeCommandIsNilWhenWorkingDirectoryDoesNotExist` covering
the new behavior.
Main's growth in `CLI/cmux.swift` and `Sources/TerminalController.swift`
since this branch forked, combined with our additions, pushed both
files above the budgets set in commit 5. Bump them with modest headroom:

- `CLI/cmux.swift`: 20900 → 20970 (current 20943; +27 headroom)
- `Sources/TerminalController.swift`: 17460 → 17500 (current 17486; +14 headroom)

`Sources/Workspace.swift` is now under its 14030 budget by 74 lines after
main's refactor; left as-is since the budget is a debt cap, not a target.

TSV sort order preserved.
@aml11
aml11 force-pushed the fix-claude-session-restore-panel-id-fallback branch from e4ae52e to d69f4e9 Compare May 10, 2026 19:56
Two CodeRabbit findings on the prior round, both quick wins:

1. `AgentResumeCommandBuilder.resumeShellCommand`: switch the cwd guard
   from `FileManager.default.fileExists(atPath:)` to the two-arg overload
   with `isDirectory:`. The single-arg form returns true for both
   directories AND regular files, so a `cwd` pointing at a leftover file
   would have passed the guard and emitted `cd <file>` — exactly the
   failure mode commit 6 introduced the guard to prevent.

2. `RestorableAgentSessionIndex` panel-id fallback fold: was
   non-deterministic on `(panelId, updatedAt)` ties because the source
   `resolved` dictionary's iteration order is unspecified. Two stale
   records sharing a timestamp could flap across launches. Sort by
   `(updatedAt desc, sessionId desc)` before folding so the same record
   always wins on tie regardless of dictionary iteration order.

Adds `testRestorableAgentIndexFallbackTieBreakIsDeterministic` covering
the new behavior — two records sharing a panelId AND timestamp; assert
the lexicographically greater sessionId wins.

Bumps `cmuxTests/SessionPersistenceTests.swift` budget 2410 → 2470 to
fit the new test (current count 2459); TSV sort still valid.
@aml11

aml11 commented May 10, 2026

Copy link
Copy Markdown
Author

Quick status update — branch rebased onto current main (was conflicting with main's socketPanelOption plumbing and the clear_agent_pid --clear-status consolidation in CLI/cmux.swift / Sources/TerminalController.swift / Sources/Workspace.swift). SHAs changed for every commit; HEAD is now c676bece. PR is MERGEABLE again.

Two more CodeRabbit findings addressed in c676bece Address review: require directory for cwd guard, deterministic tie-break:

  • ✅ Require cwd to be a directory, not just exist (Sources/RestorableAgentSession.swift:59) — switched the cwd guard from single-arg fileExists(atPath:) to the two-arg overload with isDirectory:; the old guard would have passed for regular files and let cd <file> fail at runtime, which is exactly what the guard exists to prevent.
  • ✅ Deterministic panel-id fallback tie-break (Sources/RestorableAgentSession.swift:616) — sort resolved by (updatedAt desc, sessionId desc) before folding by panelId so equal-timestamp ties pick the same record across launches instead of flapping with dictionary iteration order.

Adds testRestorableAgentIndexFallbackTieBreakIsDeterministic covering the new behavior: two records sharing a panelId AND updatedAt, assert the lexicographically greater sessionId wins.

All 6 named regression tests pass via xcodebuild test (cmux-unit scheme, Apple Silicon). File-length budgets refreshed for the post-rebase line counts; TSV stays sorted.

@coderabbitai please re-evaluate c676bece.

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown

@aml11, thanks for the rebase and the two additional fixes — here's my read before I kick off the full re-review:

c676bece

  • ✅ Directory guard tightened (Sources/RestorableAgentSession.swift:59) — the two-arg fileExists(atPath:isDirectory:) call correctly rejects regular files (and symlinks to files), so the guard now truly ensures cd <cwd> will succeed at runtime. Solid improvement over the single-arg form.
  • ✅ Deterministic tie-break (Sources/RestorableAgentSession.swift:616) — secondary sort on sessionId desc after updatedAt desc eliminates the dictionary-iteration flap for equal-timestamp records; the lexicographically-greater session ID will consistently win across launches.
  • ✅ Regression coverage — testRestorableAgentIndexFallbackTieBreakIsDeterministic pins the exact tie-break semantics so future changes can't regress silently.

Re-triggering a full review now to evaluate all commits end-to-end.

✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 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.

Inline comments:
In `@Sources/RestorableAgentSession.swift`:
- Around line 662-723: The current chunked scan in
claudeTranscriptHasConversation uses leftover.removeSubrange(...) which shifts
Data bytes on each newline and can cause O(n²) behavior for long metadata-only
transcripts; change the loop to keep an Int readOffset into leftover (append
chunks, search for newline starting at readOffset, advance readOffset past
processed bytes, and only compact leftover (by copying the unread slice into a
new Data and reset readOffset) when readOffset exceeds a threshold like
chunkSize or 64KB) while still calling lineDeclaresConversationEvent on each
extracted line; this preserves semantics but avoids repeated front-removal
costs.
- Around line 780-786: Remove the explicit private init that takes
snapshotsByPanel and snapshotsByPanelId and rely on Swift's synthesized
memberwise initializer instead; delete the init definition (the initializer that
assigns self.snapshotsByPanel and self.snapshotsByPanelId) and ensure existing
call sites such as static let empty and load(...) continue to compile using the
synthesized private memberwise init for the private let properties
snapshotsByPanel and snapshotsByPanelId.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dc940fca-4f47-4ab7-aa43-e3ced9c57a47

📥 Commits

Reviewing files that changed from the base of the PR and between f42270e and c676bec.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (5)
  • CLI/cmux.swift
  • Sources/RestorableAgentSession.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/SessionPersistenceTests.swift

Comment thread Sources/RestorableAgentSession.swift
Comment thread Sources/RestorableAgentSession.swift
@aml11

aml11 commented May 10, 2026

Copy link
Copy Markdown
Author

@coderabbitai resolve

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
✅ Actions performed

Comments resolved and changes approved.

@aml11

aml11 commented May 10, 2026

Copy link
Copy Markdown
Author

Hi @austinywang / @lawrencecchen — this PR is ready for human review when you have a minute.

What it fixes (4 independent bugs in cmux's auto-resume flow):

  1. Index lookup misses across relaunches (#2941) — RestorableAgentSessionIndex is keyed by (workspaceId, panelId) but Workspace.id is a fresh UUID() per launch, so post-relaunch the strict lookup misses and autosave drops the agent field. Adds a panel-id fallback (panel UUIDs are CMUX_SURFACE_ID and stable enough across hook records).
  2. In-lifetime SessionEnd doesn't clear in-memory state — when the user /exits claude, the on-disk record is consumed but restoredAgentSnapshotsByPanelId keeps the stale entry. Adds agent_session_ended v1 command (sessionId-scoped, so a late SessionEnd for session A can't wipe a freshly-started session B reusing the same panel) wired into claude-hook session-end and the generic-agent SessionEnd path.
  3. Auto-resume of metadata-only sessions — /rename before any prompt creates a transcript with only metadata events; claude --resume <id> rejects those with "No conversation found". Filters those records during index load AND validates embedded snapshots in Workspace.createPanel (so old snapshots from pre-fix versions also self-heal).
  4. Auto-resume into deleted cwd — cd '<cwd>' && claude --resume <id> short-circuits at the cd if the project dir was deleted, leaving a dead "no such file or directory" prompt. Returns nil from resumeShellCommand when the captured cwd no longer exists or isn't a directory, so the panel opens to a fresh shell instead.

What it includes:

Two known limitations, deferred deliberately:

  • Greptile's P1 about synchronous file I/O on @MainActor during transcript probe — async-ifying the whole restore chain is a separate refactor. Empirical cost is bounded (returns at first user/assistant event, metadata-only files <10 KB).
  • Greptile's P2 about --tab resolving stale workspace UUIDs cross-launch — same root cause as bug 1; this PR's bug 2 fix is scoped to the in-lifetime case. Cross-launch case heals on the next save once the new lifetime runs.

GitHub still shows CHANGES_REQUESTED because CodeRabbit's early review stuck — the bot has since acknowledged everything is addressed but doesn't auto-clear its state. Happy to address anything else if there are more changes you'd like.

@tomerarnon

tomerarnon commented May 14, 2026 •

Copy link
Copy Markdown

Hi @aml11! Glad to see this PR existed. I was trying to solve this same issue myself today, and only came across this after getting pretty deep into it. I tested this PR to see if it worked for auto-resuming claudes after a crash.


Tested PR #3805 against a real-world repro on cmux 0.64.4 + this PR branch. Headline: the PR's four logical fixes (workspace-UUID lookup fallback, in-lifetime SessionEnd snapshot drop, metadata-only transcript filter, missing-cwd guard) all do what they say on the tin and resolve the bugs they target. But on my machine the user-facing outcome is still "auto-resume does not fire" — because the initialInput delivery path appears not to actually execute the command in the live shell, and every fix in this PR sits upstream of that delivery step.

Empirical repro:

  1. Open a tab in any project dir, run claude, send a prompt, get a response, leave the conversation open.
  2. pkill -9 -f <tag> to force-kill cmux.
  3. Relaunch the same tagged build.

Restored terminal shows:

cd '/Users/.../proj' && '/Users/.../claude' '--resume' '<sid>'
Last login: <ts> on ttysXX
user@host proj %

The lookup fallback fires (the resume command is correctly built — that proves the workspace-UUID lookup fix is doing its job). But the bytes get echoed to the terminal's output side as visible text and the shell's input loop never consumes them. Last login appears and the user lands at a fresh prompt — claude never runs, no error, no conversation. Most likely cause: Ghostty writes initialInput to the PTY before the shell is reading.

Scope note: I only verified the user-facing effect of the lookup fallback (87f4a4). The other commits in this PR (0268c2 SessionEnd in-lifetime drop, c0ba48 metadata-only transcript filter, ea2c01 missing-cwd guard) all target failure modes that would only be user-visible after delivery works. With initialInput broken, their effects are observable only via internal state (snapshot file, hook store) rather than terminal behavior, and I did not separately exercise those paths.

Proposed complementary fix (additive — only replaces the delivery mechanism)

Move resume delivery from initialInput to a shell-side precmd hook that fires after .zshrc / .bashrc complete, guaranteeing the shell is alive.

1. Add a --only-live flag to cmux claude-hook last-session (CLI/cmux.swift near line 14089). Exits 1 if the matched record is already ended; otherwise prints record.sessionId. ~5 lines.

2. Add a new function to Resources/shell-integration/cmux-zsh-integration.zsh (and the bash mirror), called from existing _cmux_precmd right after local last_status=\$?:

_cmux_maybe_auto_resume_claude() {
    [[ -n "\$_CMUX_AUTO_RESUME_TRIED" ]] && return
    _CMUX_AUTO_RESUME_TRIED=1
    [[ -n "\$CMUX_SURFACE_ID" ]] || return
    command -v claude >/dev/null 2>&1 || return
    local cmux_bin="\${CMUX_BUNDLED_CLI_PATH:-cmux}"
    local sid
    sid=\$("\$cmux_bin" claude-hook last-session --only-live 2>/dev/null) || return
    [[ -n "\$sid" ]] || return
    print -ru2 -- "[cmux] Auto-resuming Claude session \$sid"
    if claude --resume "\$sid"; then
        exit  # matches initialInput's panel-close-on-claude-exit semantics
    fi
    print -ru2 -- "[cmux] claude --resume failed; command pre-typed for retry"
    print -z "claude --resume \$sid"
}

Guard fires once per shell. Queries the same claude-hook-sessions.json this PR maintains. Filters cleanly-/exit-ed sessions via the new flag. On normal claude exit, the shell exits to close the panel — same UX as the current initialInput path was trying to achieve.

Replaces: the initialInput: restoredAgentResumeInput argument in Workspace.swift:760-766. The Swift side can stop building/passing initialInput for restorable agents entirely; everything downstream of the lookup moves to the shell hook.

Total addition: ~75 lines across the shell-integration scripts + the CLI flag. No overlap with this PR's existing diff. I tested this approach on a separate local branch on top of cmux 0.64.4 + this PR's changes and auto-resume fires reliably in the same scenario where initialInput silently drops it.

Small integration note re: endedAt

The proposed --only-live filter needs an endedAt: TimeInterval? field persisted on ClaudeHookSessionRecord so the shell hook can distinguish a cleanly-/exit-ed session from a still-live one. This PR's markRestorableAgentSessionEnded already covers the in-memory side, but doesn't persist a per-record endedAt field an external (shell) consumer can see. Adding it would be ~5 lines on the hook handler currently consuming records on session-end (set endedAt = now instead of removing). Happy to follow up on it separately if you'd rather scope this PR to its current changes.

@teamleaderleo teamleaderleo added S2: major A crash, hang, lost state, broken connection, or a regression on a path people use area: agents Agent integrations (Claude Code, Codex, ACP), agent chat, hooks, status labels Sep 30, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: agents Agent integrations (Claude Code, Codex, ACP), agent chat, hooks, status S2: major A crash, hang, lost state, broken connection, or a regression on a path people use

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stable surface UUIDs across app restarts (or per-surface custom metadata)

3 participants