Skip to content

Restore previous sessions and resume agents - #2977

Closed
lawrencecchen wants to merge 2 commits into
mainfrom
issue-2923-reopen-sessions
Closed

lawrencecchen wants to merge 2 commits into
mainfrom
issue-2923-reopen-sessions

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Apr 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • restore the previous saved cmux session from relaunch, File > Reopen Previous Session, ⌘⇧O, or cmux restore-session
  • persist Claude Code and Codex resume metadata in session snapshots so restored panels relaunch those sessions automatically
  • add a direct overnight-relaunch regression and update the README to document the shipped behavior

Closes #2923.

Testing

  • python3 -m py_compile tests/test_session_relaunch_resumes_agent_sessions.py
  • existing regression coverage in tests/test_restore_session_relaunches_codex_resume.py
  • attempted ./scripts/reload.sh --tag issue-2923-reopen-sessions but local xcodebuild stalled in the Xcode environment before producing a compile result

Summary by cubic

Restore the last saved session and auto-resume Claude/Codex panels on relaunch or on demand. Adds a quick “Reopen Previous Session” flow to bring back windows, panes, and agent sessions.

  • New Features
    • Added File menu item, Command Palette action, and default shortcut ⌘ ⇧ O to reopen the previous session.
    • Introduced cmux restore-session CLI; restores into a running app or launches and restores if not running.
    • Persisted agent resume data in session snapshots; restored terminals run the appropriate resume command for Claude/Codex.
    • Added socket API method session.restore_previous for programmatic restores.
    • Hardened restore by caching a “previous” snapshot and skipping autosave during restore to avoid clobbering state.
    • Updated README, i18n strings, shortcut schema/UI, and added regression tests for overnight relaunch and manual restore.

Written for commit 09d1941. Summary will update on new commits.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added "Reopen Previous Session" feature accessible via ⌘⇧O shortcut, menu, and command palette
    • Session restoration now preserves Claude and Codex agent sessions with working directories
    • Added cmux restore-session CLI command for manual session restoration
  • Documentation

    • Updated with new keyboard shortcut and session restoration details
  • Localization

    • Added English and Japanese translations for reopen session feature

@vercel

vercel Bot commented Apr 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Apr 18, 2026 5:50am

@coderabbitai

coderabbitai Bot commented Apr 18, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Added a comprehensive manual session restoration feature enabling users to reopen previously saved sessions via menu, keyboard shortcut (⌘⇧O), command palette, or CLI. Integrated restorable agent session (Claude/Codex) state into persistence and snapshot hierarchy with new indexing and restoration logic.

Changes

Cohort / File(s) Summary
CLI & Socket Integration
CLI/cmux.swift, Sources/TerminalController.swift
Added restore-session CLI command with validation and JSON output. Implemented v2 socket method session.restore_previous for remote restoration triggering.
Session Restoration Core
Sources/AppDelegate.swift
Renamed restoration state flag for clarity. Added public reopenPreviousSession() API for manual restoration. Updated snapshot building to load and thread RestorableAgentSessionIndex. Added shortcut handler for .reopenPreviousSession.
Persistent Session & Agent State
Sources/SessionPersistence.swift, Sources/RestorableAgentSession.swift
Added dual-snapshot storage (default and manual-restore) via new loadReopenSessionSnapshot() and syncManualRestoreSnapshotCache(). Introduced RestorableAgentSessionIndex for Claude/Codex session lookups, with per-kind hook file parsing, UUID validation, and collision resolution.
Snapshot Propagation
Sources/TabManager.swift, Sources/Workspace.swift
Threaded RestorableAgentSessionIndex through snapshot building hierarchy. Extended Workspace.sessionSnapshot to accept agent index and propagate per-panel agent snapshots. Added agent-session state tracking with restoration of resume commands into terminal surfaces.
UI & Keyboard Integration
Sources/cmuxApp.swift, Sources/ContentView.swift, Sources/KeyboardShortcutSettings.swift
Added "Reopen Previous Session" menu item and command palette entry. Registered new keyboard shortcut action .reopenPreviousSession with ⌘⇧O default binding.
Project & Localization
GhosttyTabs.xcodeproj/project.pbxproj, Resources/Localizable.xcstrings, README.md
Added RestorableAgentSession.swift to Xcode target. Contributed localized strings (English/Japanese) for menu, command, and shortcut labels. Documented session restore behavior including agent session resumption.
Web Schema & Settings
web/data/cmux-settings.schema.json, web/data/cmux-shortcuts.ts
Extended settings schema to allow reopenPreviousSession shortcut binding override. Added shortcut entry with ⌘⇧O combo and localized descriptions.
Integration Tests
tests/test_restore_session_relaunches_codex_resume.py, tests/test_session_relaunch_resumes_agent_sessions.py
Comprehensive regression tests validating CLI restore behavior, socket reachability, hook state persistence, and agent resume command injection into terminal scrollback.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as Menu/<br/>Shortcut/<br/>Palette
    participant AppDelegate
    participant SessionStore as SessionPersistence<br/>Store
    participant AgentIndex as RestorableAgent<br/>SessionIndex
    participant Workspace
    participant Terminal as Terminal<br/>Panel

    User->>UI: Trigger "Reopen Previous Session"
    UI->>AppDelegate: reopenPreviousSession()
    
    AppDelegate->>SessionStore: loadReopenSessionSnapshot()
    SessionStore-->>AppDelegate: AppSessionSnapshot (with agent data)
    
    AppDelegate->>AgentIndex: load()
    AgentIndex-->>AppDelegate: RestorableAgentSessionIndex
    
    AppDelegate->>Workspace: Apply snapshots per-window
    Workspace->>Workspace: sessionPanelSnapshot()<br/>(with restorable agent)
    
    Workspace->>Terminal: Create surface with<br/>agent workingDirectory
    Terminal-->>Workspace: Panel ready
    
    Workspace->>Terminal: Send resumeCommand<br/>(e.g., `codex resume <id>`)
    Terminal-->>Workspace: Resume marker in scrollback
    
    Workspace-->>AppDelegate: Restoration complete
    AppDelegate-->>UI: true (success)
    UI-->>User: Beep on failure or silent on success
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 A session saved, now reborn anew,
With ⌘⇧O we hop right through—
Claude and Codex, side by side,
Resume their work with fuzzy pride! 🌟
Snap, restore, and off we go,
The rabbit's coding flow! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.47% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: restoring previous sessions and resuming agent sessions, which aligns with the primary objectives.
Description check ✅ Passed The description provides a clear summary of changes, identifies testing approaches, and references the linked issue. However, it lacks specific details on manual testing verification and does not include a demo video.
Linked Issues check ✅ Passed The PR fully implements the requirements from issue #2923: provides mechanisms to restore sessions on relaunch, automatically resume Claude/Codex sessions, and offers multiple interfaces (menu, keyboard shortcut, CLI command).
Out of Scope Changes check ✅ Passed All changes are scoped to implementing session restoration and agent resume functionality. Documentation updates, localization, test additions, and schema updates all directly support the core feature.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-2923-reopen-sessions

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.

@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: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/AppDelegate.swift (1)

3918-3927: ⚠️ Potential issue | 🟠 Major

Restore the primary window before the post-restore save.

createMainWindow(sessionWindowSnapshot:) makes each created window key, while sortedMainWindowContextsForSessionSnapshot() saves the key window first. During multi-window restore, this makes the last created window become the persisted “primary” window, and reopenPreviousSession() then brings that same last window forward instead of the snapshot’s first window.

Move primary-window activation before completeSessionRestoreOperation() saves, or make the post-restore save use the snapshot order instead of key-window order.

Suggested direction
-                    self.completeSessionRestoreOperation()
+                    self.completeSessionRestoreOperation(primaryWindow: primaryWindow)
...
-                completeSessionRestoreOperation()
+                completeSessionRestoreOperation(primaryWindow: primaryWindow)
...
-        completeSessionRestoreOperation()
+        completeSessionRestoreOperation(primaryWindow: primaryRestoredWindow)
...
-    private func completeSessionRestoreOperation() {
+    private func completeSessionRestoreOperation(primaryWindow: NSWindow? = nil) {
+        if let primaryWindow {
+            primaryWindow.makeKeyAndOrderFront(nil)
+            setActiveMainWindow(primaryWindow)
+        }
         startupSessionSnapshot = nil
         isApplyingSessionRestore = false
         _ = saveSessionSnapshot(includeScrollback: false)
     }

Also applies to: 3972-3978, 4768-4778

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 3918 - 3927, During multi-window
session restore the call sequence causes the last created window (made key by
createMainWindow(sessionWindowSnapshot:)) to be saved as the “primary” because
completeSessionRestoreOperation() reads key-window order; fix by ensuring the
intended primary window from sortedMainWindowContextsForSessionSnapshot() is
activated before the post-restore save: after creating windows, explicitly make
the first snapshot’s restored window key (or call the activation routine used
for primary selection) and only then call completeSessionRestoreOperation();
alternatively, change completeSessionRestoreOperation()’s save logic to
iterate/save windows in the snapshot order (from
sortedMainWindowContextsForSessionSnapshot()) instead of relying on current
key-window state so reopenPreviousSession() will bring forward the snapshot’s
first window rather than the last-created key window.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 3168-3178: Current branch treats the app as restored immediately
after launch when socket.connect() fails; instead, after calling launchApp() and
activateApp(), retry connecting or invoke the same restore flow used for the
running-app path (e.g. call session.restore_previous or reuse the socket restore
endpoint) and only print jsonString(["restored": true, "launched": true]) or
"OK" after that restore call succeeds; update the block around SocketClient/try?
client.connect(), client.close(), launchApp(), activateApp(),
jsonOutput/jsonString to perform the restore confirmation and handle/report any
failure from that restore before emitting success.

In `@Sources/AppDelegate.swift`:
- Around line 3949-3955: The startup restore can run inside
registerMainWindow(...) during createMainWindow(sessionWindowSnapshot:) and
overwrite the manual reopen; to prevent this, mark the startup restore as
"attempted" (or otherwise disable attemptStartupSessionRestoreIfNeeded()) before
creating windows when applying a session restore: in the block where
isApplyingSessionRestore is set and you iterate snapshotWindows (use
sortedMainWindowContextsForSessionSnapshot(), isApplyingSessionRestore,
snapshotWindows, createMainWindow(sessionWindowSnapshot:)), set the flag or call
the method that records the startup restore has been attempted so
registerMainWindow(...) won't call attemptStartupSessionRestoreIfNeeded(...) and
clobber the manual snapshot.

In `@Sources/RestorableAgentSession.swift`:
- Around line 27-39: The resumeCommand currently ignores the captured
workingDirectory; update SessionRestorableAgentSnapshot.resumeCommand to prepend
a guarded cd when workingDirectory is non-nil by using shellSingleQuoted to
quote it, e.g. return "cd <quoted cwd> && <originalResume>" so restored agents
run in the persisted cwd; use the existing private static func
shellSingleQuoted(_:) to quote workingDirectory and keep the original
kind.resumeCommand(sessionId:) as the command tail.

In `@Sources/Workspace.swift`:
- Around line 269-275: The current call to
restorableAgentIndex?.snapshot(workspaceId: id, panelId: panelId) conflates
"index not loaded" and "index loaded but no agent" into nil, causing
sessionPanelSnapshot to fall back to restoredAgentSnapshotsByPanelId and
re-persist stale agent data; change the logic so you first detect whether
restorableAgentIndex is present (e.g. capture the optional index in a variable),
and then: if the index is nil, allow sessionPanelSnapshot to use
restoredAgentSnapshotsByPanelId; if the index is non-nil but snapshot(...)
returns nil, treat that as an authoritative miss and pass an explicit “no agent”
marker (or nil but accompanied by a flag) to sessionPanelSnapshot so it clears
any cached restoredAgentSnapshotsByPanelId for that panel; update
sessionPanelSnapshot (and its callers) to accept and honor this distinction.
- Around line 692-693: The resume logic currently sends the raw resumeCommand
(snapshot.terminal?.agent?.resumeCommand) into sendInputWhenReady which can land
in the wrong directory if shell RC files cd elsewhere; update the Workspace code
that triggers sending the resume command to wrap the command with the restored
working directory using a helper like resumeCommandWithCwd(_:workingDirectory:)
(or reuse the existing session-entry cwd guard), and implement
shellSingleQuoted(_:) to safely single-quote and escape the cwd; then call
sendInputWhenReady(resumeCommandWithCwd(resumeCommand, workingDirectory:
snapshot.terminal?.agent?.workingDirectory) + "\n", to: terminalPanel) so all
resume call sites use the guarded form.

In `@tests/test_restore_session_relaunches_codex_resume.py`:
- Around line 176-180: The test directly computes and deletes real user files
(bundle_id via _bundle_id, socket_path, snapshot/previous_snapshot via
_snapshot_path, and hook_state path) which risks destroying a developer's
session state; change the test to isolate its filesystem operations by creating
a temporary home/app-support environment (e.g., use tempfile.TemporaryDirectory
or a tmp_path fixture), stub or override _bundle_id/_snapshot_path to return
paths inside that temp directory, and for hook_state either point it to a temp
file or back up the real ~/.cmuxterm/codex-hook-sessions.json before the test
and restore it after—ensure all cleanup happens in a finally/teardown so real
user files are never deleted.

In `@tests/test_session_relaunch_resumes_agent_sessions.py`:
- Around line 175-180: The test constructs real user file paths (via _bundle_id,
_snapshot_path) and variables bundle_id, socket_path, snapshot,
previous_snapshot, codex_hook_state, claude_hook_state and then deletes them,
risking wiping a developer's real session state; modify the test to isolate
these paths by using a temporary HOME or app-support fixture (e.g., set
Path.home() patch or use tmp_path to create a temp directory) or implement
explicit backup-and-restore around those files so the test operates only on temp
copies and restores any pre-existing
~/.cmuxterm/{codex,claude}-hook-sessions.json and per-bundle snapshots after the
test, making sure every reference to _bundle_id/_snapshot_path in this test (and
the similar blocks at 193-197 and 273-279) points to the temp fixture instead of
the real home directory.

---

Outside diff comments:
In `@Sources/AppDelegate.swift`:
- Around line 3918-3927: During multi-window session restore the call sequence
causes the last created window (made key by
createMainWindow(sessionWindowSnapshot:)) to be saved as the “primary” because
completeSessionRestoreOperation() reads key-window order; fix by ensuring the
intended primary window from sortedMainWindowContextsForSessionSnapshot() is
activated before the post-restore save: after creating windows, explicitly make
the first snapshot’s restored window key (or call the activation routine used
for primary selection) and only then call completeSessionRestoreOperation();
alternatively, change completeSessionRestoreOperation()’s save logic to
iterate/save windows in the snapshot order (from
sortedMainWindowContextsForSessionSnapshot()) instead of relying on current
key-window state so reopenPreviousSession() will bring forward the snapshot’s
first window rather than the last-created key window.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: f846ec46-170a-491c-a53f-6aec289a9515

📥 Commits

Reviewing files that changed from the base of the PR and between 060eafd and 09d1941.

📒 Files selected for processing (17)
  • CLI/cmux.swift
  • GhosttyTabs.xcodeproj/project.pbxproj
  • README.md
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate.swift
  • Sources/ContentView.swift
  • Sources/KeyboardShortcutSettings.swift
  • Sources/RestorableAgentSession.swift
  • Sources/SessionPersistence.swift
  • Sources/TabManager.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • Sources/cmuxApp.swift
  • tests/test_restore_session_relaunches_codex_resume.py
  • tests/test_session_relaunch_resumes_agent_sessions.py
  • web/data/cmux-settings.schema.json
  • web/data/cmux-shortcuts.ts

Comment thread CLI/cmux.swift
Comment on lines +3168 to +3178
let client = SocketClient(path: socketPath)
if (try? client.connect()) == nil {
client.close()
try launchApp()
try activateApp()
if jsonOutput {
print(jsonString(["restored": true, "launched": true]))
} else {
print("OK")
}
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t report restore success before the app confirms it.

When the socket is unavailable, this branch prints OK / "restored": true immediately after open -a cmux. That can mask launch failures and returns success for the same “no previous snapshot” case that the running-app path correctly surfaces via session.restore_previous.

Proposed fix: launch if needed, then use the same restore endpoint
-        let client = SocketClient(path: socketPath)
-        if (try? client.connect()) == nil {
-            client.close()
-            try launchApp()
-            try activateApp()
-            if jsonOutput {
-                print(jsonString(["restored": true, "launched": true]))
-            } else {
-                print("OK")
-            }
-            return
-        }
-
-        defer { client.close() }
-        try authenticateClientIfNeeded(
-            client,
-            explicitPassword: explicitPassword,
-            socketPath: socketPath
-        )
-
-        let response = try client.sendV2(method: "session.restore_previous")
+        let probeClient = SocketClient(path: socketPath)
+        let alreadyRunning = (try? probeClient.connect()) != nil
+        probeClient.close()
+
+        let client = try connectClient(
+            socketPath: socketPath,
+            explicitPassword: explicitPassword,
+            launchIfNeeded: true
+        )
+        defer { client.close() }
+
+        let response = try client.sendV2(method: "session.restore_previous")
+        try activateApp()
         if jsonOutput {
-            print(jsonString(response))
+            var output = response
+            output["launched"] = !alreadyRunning
+            print(jsonString(output))
         } else {
             print("OK")
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 3168 - 3178, Current branch treats the app as
restored immediately after launch when socket.connect() fails; instead, after
calling launchApp() and activateApp(), retry connecting or invoke the same
restore flow used for the running-app path (e.g. call session.restore_previous
or reuse the socket restore endpoint) and only print jsonString(["restored":
true, "launched": true]) or "OK" after that restore call succeeds; update the
block around SocketClient/try? client.connect(), client.close(), launchApp(),
activateApp(), jsonOutput/jsonString to perform the restore confirmation and
handle/report any failure from that restore before emitting success.

Comment thread Sources/AppDelegate.swift
Comment on lines +3949 to +3955
let existingContexts = sortedMainWindowContextsForSessionSnapshot()
isApplyingSessionRestore = true

if existingContexts.isEmpty {
for windowSnapshot in snapshotWindows {
_ = createMainWindow(sessionWindowSnapshot: windowSnapshot)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Prevent startup restore from clobbering manual reopen.

When there are no registered main contexts, createMainWindow(sessionWindowSnapshot:) registers the new window, and registerMainWindow(...) then calls attemptStartupSessionRestoreIfNeeded(...) at Line 4917. If startup restore has not been marked attempted yet, the cached startup snapshot can overwrite the manual reopen snapshot being applied here.

Suggested fix
         let existingContexts = sortedMainWindowContextsForSessionSnapshot()
+        startupSessionSnapshot = nil
+        didAttemptStartupSessionRestore = true
         isApplyingSessionRestore = true
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 3949 - 3955, The startup restore can
run inside registerMainWindow(...) during
createMainWindow(sessionWindowSnapshot:) and overwrite the manual reopen; to
prevent this, mark the startup restore as "attempted" (or otherwise disable
attemptStartupSessionRestoreIfNeeded()) before creating windows when applying a
session restore: in the block where isApplyingSessionRestore is set and you
iterate snapshotWindows (use sortedMainWindowContextsForSessionSnapshot(),
isApplyingSessionRestore, snapshotWindows,
createMainWindow(sessionWindowSnapshot:)), set the flag or call the method that
records the startup restore has been attempted so registerMainWindow(...) won't
call attemptStartupSessionRestoreIfNeeded(...) and clobber the manual snapshot.

Comment on lines +27 to +39
private static func shellSingleQuoted(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
}

struct SessionRestorableAgentSnapshot: Codable, Sendable {
var kind: RestorableAgentKind
var sessionId: String
var workingDirectory: String?

var resumeCommand: String {
kind.resumeCommand(sessionId: sessionId)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard agent resumes with the persisted cwd.

workingDirectory is captured but ignored when generating resumeCommand, so restored Claude/Codex panels can resume in the wrong project directory. Compose the command as cd <quoted cwd> && <agent resume> when a cwd is available.

🐛 Proposed fix
-    private static func shellSingleQuoted(_ value: String) -> String {
+    fileprivate static func shellSingleQuoted(_ value: String) -> String {
         "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
     }
 }
@@
     var resumeCommand: String {
-        kind.resumeCommand(sessionId: sessionId)
+        let command = kind.resumeCommand(sessionId: sessionId)
+        guard let workingDirectory else { return command }
+        return "cd \(RestorableAgentKind.shellSingleQuoted(workingDirectory)) && \(command)"
     }
 }

Based on learnings, session resume commands must use a guaranteed cwd guard: cd <shell-quoted cwd> && <resumeCommand>.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private static func shellSingleQuoted(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
}
struct SessionRestorableAgentSnapshot: Codable, Sendable {
var kind: RestorableAgentKind
var sessionId: String
var workingDirectory: String?
var resumeCommand: String {
kind.resumeCommand(sessionId: sessionId)
}
fileprivate static func shellSingleQuoted(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
}
struct SessionRestorableAgentSnapshot: Codable, Sendable {
var kind: RestorableAgentKind
var sessionId: String
var workingDirectory: String?
var resumeCommand: String {
let command = kind.resumeCommand(sessionId: sessionId)
guard let workingDirectory else { return command }
return "cd \(RestorableAgentKind.shellSingleQuoted(workingDirectory)) && \(command)"
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/RestorableAgentSession.swift` around lines 27 - 39, The resumeCommand
currently ignores the captured workingDirectory; update
SessionRestorableAgentSnapshot.resumeCommand to prepend a guarded cd when
workingDirectory is non-nil by using shellSingleQuoted to quote it, e.g. return
"cd <quoted cwd> && <originalResume>" so restored agents run in the persisted
cwd; use the existing private static func shellSingleQuoted(_:) to quote
workingDirectory and keep the original kind.resumeCommand(sessionId:) as the
command tail.

Comment thread Sources/Workspace.swift
Comment on lines +269 to +275
.compactMap { panelId in
sessionPanelSnapshot(
panelId: panelId,
includeScrollback: includeScrollback,
restorableAgent: restorableAgentIndex?.snapshot(workspaceId: id, panelId: panelId)
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid re-persisting stale agent resume metadata.

restorableAgentIndex?.snapshot(...) collapses “index unavailable” and “no active/restorable agent for this panel” into nil. Because sessionPanelSnapshot then falls back to restoredAgentSnapshotsByPanelId, a once-restored panel can keep writing its old agent forever after the live index no longer reports it, causing future restores to relaunch a stale Claude/Codex session.

Treat a loaded index miss as authoritative and clear the cached snapshot; only fall back to restoredAgentSnapshotsByPanelId when the index itself is unavailable.

Proposed direction
-                    restorableAgent: restorableAgentIndex?.snapshot(workspaceId: id, panelId: panelId)
+                    restorableAgent: restorableAgentIndex?.snapshot(workspaceId: id, panelId: panelId),
+                    restorableAgentLookupAvailable: restorableAgentIndex != nil
-        restorableAgent: SessionRestorableAgentSnapshot?
+        restorableAgent: SessionRestorableAgentSnapshot?,
+        restorableAgentLookupAvailable: Bool
-        let effectiveRestorableAgent = restorableAgent ?? restoredAgentSnapshotsByPanelId[panelId]
-        if let restorableAgent {
-            restoredAgentSnapshotsByPanelId[panelId] = restorableAgent
-        }
+        let effectiveRestorableAgent: SessionRestorableAgentSnapshot?
+        if restorableAgentLookupAvailable {
+            effectiveRestorableAgent = restorableAgent
+            if let restorableAgent {
+                restoredAgentSnapshotsByPanelId[panelId] = restorableAgent
+            } else {
+                restoredAgentSnapshotsByPanelId.removeValue(forKey: panelId)
+            }
+        } else {
+            effectiveRestorableAgent = restoredAgentSnapshotsByPanelId[panelId]
+        }

Also applies to: 449-493

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 269 - 275, The current call to
restorableAgentIndex?.snapshot(workspaceId: id, panelId: panelId) conflates
"index not loaded" and "index loaded but no agent" into nil, causing
sessionPanelSnapshot to fall back to restoredAgentSnapshotsByPanelId and
re-persist stale agent data; change the logic so you first detect whether
restorableAgentIndex is present (e.g. capture the optional index in a variable),
and then: if the index is nil, allow sessionPanelSnapshot to use
restoredAgentSnapshotsByPanelId; if the index is non-nil but snapshot(...)
returns nil, treat that as an authoritative miss and pass an explicit “no agent”
marker (or nil but accompanied by a flag) to sessionPanelSnapshot so it clears
any cached restoredAgentSnapshotsByPanelId for that panel; update
sessionPanelSnapshot (and its callers) to accept and honor this distinction.

Comment thread Sources/Workspace.swift
Comment on lines +692 to +693
if let resumeCommand = snapshot.terminal?.agent?.resumeCommand {
sendInputWhenReady(resumeCommand + "\n", to: terminalPanel)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard restored agent resumes with the intended cwd.

This sends the bare claude --resume / codex resume command after the new shell starts. If shell rc files cd elsewhere before sendInputWhenReady fires, the resumed agent lands in the wrong directory. Wrap the resume with cd <shell-quoted cwd> && ... using the restored terminal/agent working directory.

Proposed direction
-            if let resumeCommand = snapshot.terminal?.agent?.resumeCommand {
-                sendInputWhenReady(resumeCommand + "\n", to: terminalPanel)
+            if let agent = snapshot.terminal?.agent {
+                let resumeCommand = Self.resumeCommandWithCwd(
+                    agent.resumeCommand,
+                    workingDirectory: workingDirectory
+                )
+                sendInputWhenReady(resumeCommand + "\n", to: terminalPanel)
             }

Add the helper in this Workspace extension or reuse the existing session-entry cwd guard helper:

private static func resumeCommandWithCwd(_ command: String, workingDirectory: String?) -> String {
    guard let cwd = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines),
          !cwd.isEmpty else {
        return command
    }
    return "cd \(shellSingleQuoted(cwd)) && \(command)"
}

private static func shellSingleQuoted(_ value: String) -> String {
    "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
}

Based on learnings, “all call sites that generate ‘resume’ commands … should use this form so fresh shells and rc files cannot land outside the intended directory.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 692 - 693, The resume logic currently
sends the raw resumeCommand (snapshot.terminal?.agent?.resumeCommand) into
sendInputWhenReady which can land in the wrong directory if shell RC files cd
elsewhere; update the Workspace code that triggers sending the resume command to
wrap the command with the restored working directory using a helper like
resumeCommandWithCwd(_:workingDirectory:) (or reuse the existing session-entry
cwd guard), and implement shellSingleQuoted(_:) to safely single-quote and
escape the cwd; then call sendInputWhenReady(resumeCommandWithCwd(resumeCommand,
workingDirectory: snapshot.terminal?.agent?.workingDirectory) + "\n", to:
terminalPanel) so all resume call sites use the guarded form.

Comment on lines +176 to +180
bundle_id = _bundle_id(app_path)
socket_path = Path(f"/tmp/cmux-restore-session-codex-{bundle_id.replace('.', '-')}.sock")
snapshot = _snapshot_path(bundle_id)
previous_snapshot = _snapshot_path(bundle_id, suffix="-previous")
hook_state = Path.home() / ".cmuxterm" / "codex-hook-sessions.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid deleting real user session state from the test.

This test removes the actual cmux snapshot files and ~/.cmuxterm/codex-hook-sessions.json. That can destroy a developer’s saved restore state when the test is run against a local app bundle. Please isolate the app’s home/app-support paths for the test or backup and restore these files.

Also applies to: 189-192, 272-277

🧰 Tools
🪛 Ruff (0.15.10)

[error] 177-177: Probable insecure usage of temporary file or directory: "/tmp/cmux-restore-session-codex-"

(S108)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_restore_session_relaunches_codex_resume.py` around lines 176 -
180, The test directly computes and deletes real user files (bundle_id via
_bundle_id, socket_path, snapshot/previous_snapshot via _snapshot_path, and
hook_state path) which risks destroying a developer's session state; change the
test to isolate its filesystem operations by creating a temporary
home/app-support environment (e.g., use tempfile.TemporaryDirectory or a
tmp_path fixture), stub or override _bundle_id/_snapshot_path to return paths
inside that temp directory, and for hook_state either point it to a temp file or
back up the real ~/.cmuxterm/codex-hook-sessions.json before the test and
restore it after—ensure all cleanup happens in a finally/teardown so real user
files are never deleted.

Comment on lines +175 to +180
bundle_id = _bundle_id(app_path)
socket_path = Path(f"/tmp/cmux-session-relaunch-agents-{bundle_id.replace('.', '-')}.sock")
snapshot = _snapshot_path(bundle_id)
previous_snapshot = _snapshot_path(bundle_id, suffix="-previous")
codex_hook_state = Path.home() / ".cmuxterm" / "codex-hook-sessions.json"
claude_hook_state = Path.home() / ".cmuxterm" / "claude-hook-sessions.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid deleting real user session state from the test.

This test unlinks the actual per-bundle cmux snapshots and ~/.cmuxterm/{codex,claude}-hook-sessions.json. Running it locally can wipe a developer’s saved session/resume metadata. Please isolate these paths under a temp home/app-support fixture, or backup and restore the existing files around the test.

Also applies to: 193-197, 273-279

🧰 Tools
🪛 Ruff (0.15.10)

[error] 176-176: Probable insecure usage of temporary file or directory: "/tmp/cmux-session-relaunch-agents-"

(S108)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_session_relaunch_resumes_agent_sessions.py` around lines 175 -
180, The test constructs real user file paths (via _bundle_id, _snapshot_path)
and variables bundle_id, socket_path, snapshot, previous_snapshot,
codex_hook_state, claude_hook_state and then deletes them, risking wiping a
developer's real session state; modify the test to isolate these paths by using
a temporary HOME or app-support fixture (e.g., set Path.home() patch or use
tmp_path to create a temp directory) or implement explicit backup-and-restore
around those files so the test operates only on temp copies and restores any
pre-existing ~/.cmuxterm/{codex,claude}-hook-sessions.json and per-bundle
snapshots after the test, making sure every reference to
_bundle_id/_snapshot_path in this test (and the similar blocks at 193-197 and
273-279) points to the temp fixture instead of the real home directory.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 09d19411b8

ℹ️ 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".

Comment thread CLI/cmux.swift
Comment on lines +3171 to +3174
try launchApp()
try activateApp()
if jsonOutput {
print(jsonString(["restored": true, "launched": true]))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Call restore after launching app

When cmux restore-session can't connect, this branch only launches/activates the app and returns success, but never invokes session.restore_previous. That breaks the recovery case where session-<bundle>.json is already blank and only session-<bundle>-previous.json has the desired state: startup immediately runs SessionPersistenceStore.syncManualRestoreSnapshotCache() (Sources/AppDelegate.swift), which copies the blank snapshot over -previous, so the old session is lost before any restore call happens. In this scenario the command reports OK/{"restored":true} even though nothing was restored.

Useful? React with 👍 / 👎.

@austinywang

Copy link
Copy Markdown
Contributor

Closing and reopening via gh CLI per request.

@greptile-apps

greptile-apps Bot commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds "Reopen Previous Session" functionality — a ⌘⇧O shortcut, menu item, command-palette action, and cmux restore-session CLI — that restores windows, panes, and auto-resumes Claude/Codex sessions. Agent resume metadata (session ID, working directory) is now embedded in session JSON snapshots at quit time so hook files are not needed on relaunch. A -previous snapshot file is cached at startup so the restore always targets the pre-current-session state.

The architecture is well-designed, but the regression test was committed after the fix rather than before it, which directly violates the project's two-commit regression test policy.

Confidence Score: 4/5

Safe to merge with minor follow-ups; the P1 is a process violation that doesn't affect runtime correctness.

The core implementation is solid — agent data is correctly embedded in session snapshots at quit time, shell quoting is correct, isApplyingSessionRestore lifecycle is properly managed, and restoreSessionSnapshot safely tears down old state before rebuilding. The P1 finding is a CLAUDE.md policy violation (test committed after the fix), meaning CI never proved the regression test catches the bug. P2 findings are non-blocking.

Sources/SessionPersistence.swift (fallback logic), CLI/cmux.swift (restore-session asymmetry), tests/test_session_relaunch_resumes_agent_sessions.py (commit order + workspace index)

Important Files Changed

Filename Overview
Sources/RestorableAgentSession.swift New file defining agent session types; shell single-quoting is correct, index loading logic is sound.
Sources/SessionPersistence.swift Adds -previous snapshot caching and loadReopenSessionSnapshot; the fallback to the current snapshot when no previous file exists can silently replace the live session with itself.
Sources/AppDelegate.swift Adds reopenPreviousSession() and startup cache sync; isApplyingSessionRestore lifecycle is correctly managed throughout the new code path.
Sources/TerminalController.swift Adds session.restore_previous socket command; correctly placed in focusIntentV2Methods consistent with window.focus behavior.
Sources/Workspace.swift Embeds agent snapshot in SessionTerminalPanelSnapshot and sends resume command via sendInputWhenReady on restore; logic is clean.
CLI/cmux.swift Adds cmux restore-session; the not-running path performs a normal launch (restores current snapshot) while the running path targets the -previous snapshot — an asymmetry worth documenting.
tests/test_session_relaunch_resumes_agent_sessions.py Good E2E coverage structure, but committed after the fix (policy violation) and uses positional workspace index selection which is fragile under async ordering.
Sources/TabManager.swift Updated sessionSnapshot signature to accept RestorableAgentSessionIndex; existing restoreSessionSnapshot correctly tears down old tabs before rebuilding.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A([User triggers Reopen Previous Session]) --> B{App running?}
    B -- No --> C[cmux restore-session launches app normally]
    C --> D[Startup restore reads default snapshot]
    B -- Yes --> F{Via socket?}
    F -- Yes --> G[session.restore_previous socket command]
    F -- No --> H[reopenPreviousSession directly]
    G --> H
    H --> I[loadReopenSessionSnapshot]
    I --> J{-previous file exists?}
    J -- Yes --> K[Load -previous snapshot]
    J -- No --> L[Fallback: load default snapshot]
    K --> M[isApplyingSessionRestore = true]
    L --> M
    M --> N{Existing windows?}
    N -- No --> O[createMainWindow for each snapshot window]
    N -- Yes --> P[restoreSessionSnapshot: tear down existing tabs, rebuild]
    O --> Q[completeSessionRestoreOperation]
    P --> Q
    Q --> R[makeKeyAndOrderFront + NSApp activate]
    style L fill:#ffddaa,stroke:#cc8800
Loading

Reviews (1): Last reviewed commit: "Add relaunch regression coverage for ses..." | Re-trigger Greptile

Comment on lines +1 to +10
#!/usr/bin/env python3
"""
Regression: normal relaunch should resume saved Claude/Codex sessions.

Repro for issue #2923:
1) Launch cmux and seed workspaces with tracked Claude/Codex sessions.
2) Quit the app normally so the session snapshot is saved.
3) Relaunch cmux the next day.
4) Verify the restored panels automatically run the saved resume commands.
"""

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 Regression test commit order violates CLAUDE.md policy

The CLAUDE.md regression test policy requires: Commit 1 = failing test only (CI goes red), Commit 2 = the fix (CI goes green). In this PR the order is reversed — 7f4d8c38 ships the fix first, and this test is tacked on in the follow-up commit 09d19411. CI therefore never ran this test in a state where it could fail, so there's no proof the test actually catches the bug. This also makes it harder to revert just the fix while keeping the test as a guard.

Context Used: CLAUDE.md (source)

Comment on lines +405 to +410
static func loadReopenSessionSnapshot(fileURL: URL? = nil) -> AppSessionSnapshot? {
if let manualRestoreSnapshot = load(fileURL: fileURL ?? manualRestoreSnapshotFileURL()) {
return manualRestoreSnapshot
}
return load()
}

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 Silent fallback to current-session snapshot

When no -previous snapshot file exists (first-ever run, or after a fresh install), loadReopenSessionSnapshot() falls through to load() and returns the current running session's snapshot. reopenPreviousSession() then calls restoreSessionSnapshot, which is destructive — it tears down existing workspaces and re-creates them from the snapshot. If the user triggers ⌘⇧O before ever quitting once, they get the current session replaced with itself (minus any work done since the last autosave tick). Consider returning nil when the previous-session file is absent so reopenPreviousSession can beep instead.

Suggested change
static func loadReopenSessionSnapshot(fileURL: URL? = nil) -> AppSessionSnapshot? {
if let manualRestoreSnapshot = load(fileURL: fileURL ?? manualRestoreSnapshotFileURL()) {
return manualRestoreSnapshot
}
return load()
}
static func loadReopenSessionSnapshot(fileURL: URL? = nil) -> AppSessionSnapshot? {
load(fileURL: fileURL ?? manualRestoreSnapshotFileURL())
}

Comment thread CLI/cmux.swift
Comment on lines +3168 to +3179
let client = SocketClient(path: socketPath)
if (try? client.connect()) == nil {
client.close()
try launchApp()
try activateApp()
if jsonOutput {
print(jsonString(["restored": true, "launched": true]))
} else {
print("OK")
}
return
}

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 restore-session (app-not-running path) skips the -previous snapshot

When the app is not running, runRestoreSession launches cmux normally and returns immediately. The startup restore reads the default (current) snapshot — not the -previous one. But when the app IS running, session.restore_previous reads from the -previous file (state before the current session). A running-app restore gives "yesterday's" state while a cold-launch restore gives the most recent autosave. Consider documenting this asymmetry in the help text, as it directly affects the stated overnight-relaunch use-case.

Comment on lines +249 to +253
def workspace_contains(index: int, expected: str) -> bool:
if len(client.list_workspaces()) <= index:
return False
client.select_workspace(index)
return expected in _read_scrollback(client)

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 Workspace selection by index is fragile

workspace_contains selects by positional index (0, 1) inside the _wait_for_condition polling loop. If restored workspaces arrive in a different order the predicate will check the wrong workspace and the test could pass spuriously. Consider recording workspace IDs during setup and selecting by ID instead.

Suggested change
def workspace_contains(index: int, expected: str) -> bool:
if len(client.list_workspaces()) <= index:
return False
client.select_workspace(index)
return expected in _read_scrollback(client)
def workspace_contains(ws_id: str, expected: str) -> bool:
client.select_workspace(ws_id)
return expected in _read_scrollback(client)

This branch was successfully deployed

1 active deployment
Preview — 09d19411 Deployed Apr 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Easy way to re-open existing sessions after turning laptop back on

2 participants