Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
379cd8f
test: newer-schema session snapshot is destroyed by the next save
teamleaderleo Sep 26, 2026
497ed7d
fix: keep newer-schema session snapshots; add snapshot import/export …
teamleaderleo Sep 26, 2026
900e6f4
feat: move saved sessions between cmux installs with restore-session …
teamleaderleo Sep 26, 2026
611ea21
fix: address review of session import/export
teamleaderleo Sep 26, 2026
32c70b2
fix: don't auto-run resume commands from imported session files
teamleaderleo Sep 26, 2026
bed6dda
fix: harden untrusted session file imports
teamleaderleo Sep 26, 2026
8f0dc80
fix: make SessionSnapshotFileLocation an instantiable value
teamleaderleo Sep 26, 2026
f71ec94
Fix session snapshot transfer edge cases
teamleaderleo Sep 27, 2026
4eb25e6
test(session): preserve newer backup before cache replacement
teamleaderleo Sep 27, 2026
3479700
fix(session): preserve newer backup before cache sync
teamleaderleo Sep 27, 2026
2057d16
test(cli): clean transfer harness on setup failure
teamleaderleo Sep 27, 2026
0f07b85
Merge main into feat/restore-session-from-channel
teamleaderleo Sep 27, 2026
d9d478a
Merge remote-tracking branch 'upstream/main' into feat/restore-sessio…
teamleaderleo Sep 28, 2026
e762e8f
Keep main's string catalog order when merging the session transfer st…
teamleaderleo Sep 28, 2026
efc7f1e
restore-session transfer: harden import reads, backup sync, and CLI p…
teamleaderleo Sep 28, 2026
029d12c
Use a computed static for the import size cap in the generic repository
teamleaderleo Sep 28, 2026
de65cfd
Check snapshot file type and size on the descriptor that is read
teamleaderleo Sep 28, 2026
0484ead
README: show --force for replacing an existing session export
teamleaderleo Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLI/CMUXCLI+TaskHelp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ extension CMUXCLI {
return """
\(restoreCommandUsageLine)
\(forkCommandUsageLine)
restore-session
restore-session [--from <channel|path> | --export <path> [--force]]
\(String(localized: "cli.sessions.command", defaultValue: "sessions [list] [options]"))
open <path-or-url>... [--workspace <id|ref|index>] [--surface <id|ref|index>] [--pane <id|ref|index>] [--window <id|ref|index>] [--focus <true|false>] [--no-focus]
new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] [--group <id|ref>] [--group-placement afterCurrent|top|end] [--group-reference <workspace>]
Expand Down
140 changes: 137 additions & 3 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8600,10 +8600,48 @@ struct CMUXCLI {
explicitPassword: String?,
jsonOutput: Bool
) throws {
let remaining = commandArgs.filter { $0 != "--" }
let (fromValue, afterFrom) = parseOption(commandArgs, name: "--from")
let (exportValue, afterExport) = parseOption(afterFrom, name: "--export")
let force = afterExport.contains("--force")
let remaining = afterExport.filter { $0 != "--" && $0 != "--force" }
if let unknown = remaining.first {
if unknown == "--from" || unknown == "--export" {
throw CLIError(message: "restore-session: \(unknown) requires a value")
}
throw CLIError(message: "restore-session: unknown flag '\(unknown)'")
}
if fromValue != nil && exportValue != nil {
throw CLIError(message: "restore-session: use either --from or --export, not both")
}
if force && exportValue == nil {
throw CLIError(message: "restore-session: --force only applies to --export")
}
if let fromValue {
try runRestoreSessionTransfer(
method: "session.import",
params: try restoreSessionImportParams(fromValue),
socketPath: socketPath,
explicitPassword: explicitPassword,
jsonOutput: jsonOutput,
resultPathKey: "source_path"
)
return
}
if let exportValue {
let trimmed = exportValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw CLIError(message: "restore-session: --export requires a file path")
}
try runRestoreSessionTransfer(
method: "session.export",
params: ["path": resolvePath(trimmed), "force": force],
socketPath: socketPath,
explicitPassword: explicitPassword,
jsonOutput: jsonOutput,
resultPathKey: "path"
)
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

let initialClient = SocketClient(path: socketPath)
let client: SocketClient
Expand Down Expand Up @@ -8635,6 +8673,90 @@ struct CMUXCLI {
}
}

/// `session.import` params for `restore-session --from <value>`: a value
/// that looks like a file path (contains `/`, starts with `~` or `.`, or
/// ends in `.json`) is sent as an absolute `path`; anything else is a
/// channel name or bundle id the app resolves (`source`).
func restoreSessionImportParams(_ rawValue: String) throws -> [String: Any] {
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else {
throw CLIError(message: "restore-session: --from requires a channel or a file path")
}
let resolvedPath = resolvePath(value)
let looksLikePath = value.contains("/")
|| value.hasPrefix("~")
|| value.hasPrefix(".")
|| value.lowercased().hasSuffix(".json")
|| FileManager.default.fileExists(atPath: resolvedPath)
if looksLikePath {
return ["path": resolvedPath]
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return ["source": value]
}

/// Sends a session transfer request (`session.import` / `session.export`)
/// to the running app. Unlike plain `restore-session`, these never launch
/// cmux: importing into an app that is still starting would race its own
/// startup restore.
private func runRestoreSessionTransfer(
method: String,
params: [String: Any],
socketPath: String,
explicitPassword: String?,
jsonOutput: Bool,
resultPathKey: String
) throws {
let client = SocketClient(path: socketPath)
do {
try client.connect()
} catch {
client.close()
throw CLIError(message: "restore-session: cmux is not running. Open cmux, then run this command again.")
}
defer { client.close() }
try authenticateClientIfNeeded(
client,
explicitPassword: explicitPassword,
socketPath: socketPath
)
let response = try client.sendV2(method: method, params: params)
if jsonOutput {
print(jsonString(response))
return
}
if let path = response[resultPathKey] as? String {
print("OK \(path)")
} else {
print("OK")
}
for line in restoreSessionImportNotes(response) {
print(line)
}
}

/// Follow-up lines for a file import that held back automatic resume or
/// dropped remote connections.
func restoreSessionImportNotes(_ response: [String: Any]) -> [String] {
var lines: [String] = []
let heldBack = (response["held_back_resume_count"] as? NSNumber)?.intValue ?? 0
if heldBack > 0 {
lines.append(
"Held back automatic resume in \(heldBack) terminal\(heldBack == 1 ? "" : "s") from this file. "
+ "In each one, run `cmux surface resume show` to inspect the command "
+ "and `cmux restore --surface` to run it. "
+ "Use --from <channel> to import another install's session with automatic resume."
)
}
let droppedRemote = (response["dropped_remote_workspace_count"] as? NSNumber)?.intValue ?? 0
if droppedRemote > 0 {
lines.append(
"Opened \(droppedRemote) workspace\(droppedRemote == 1 ? "" : "s") without the file's "
+ "SSH/cloud connection and environment variables."
)
}
return lines
}

func connectClient(
socketPath: String,
explicitPassword: String?,
Expand Down Expand Up @@ -18519,14 +18641,26 @@ struct CMUXCLI {
Configure idle and live-terminal limits from Settings or cmux settings JSON.
"""
case "restore-session":
return """
return String(localized: "cli.restoreSession.help", defaultValue: """
Usage: cmux restore-session
cmux restore-session --from <stable|nightly|rc|staging|debug:<tag>|path>
cmux restore-session --export <path> [--force]

Reopen the previous saved cmux session.

If the app is already running, this restores the last saved session into the current app.
If the app is not running, this launches cmux and lets startup restore reopen the saved session.
"""

Each cmux install (stable, nightly, rc, staging, tagged debug builds) saves its own session.
--from <channel> Reopen another install's saved session in this running cmux, for example
after trying nightly and switching back to stable. The session opens as
additional windows; the other install's saved file is only read.
--from <path> Reopen a session file written by --export.
--export <path> Write this cmux's saved session to a file. Pass --force to replace an
existing file.

--from and --export require cmux to be running.
""")
case "restore":
return String(localized: "cli.restore.help", defaultValue: """
Usage: cmux restore [--surface <id|ref>] <kind> <checkpoint-id>
Expand Down
4 changes: 4 additions & 0 deletions Packages/macOS/CmuxControlSocket/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import PackageDescription

let package = Package(
name: "CmuxControlSocket",
defaultLocalization: "en",
platforms: [
.macOS(.v14),
],
Expand All @@ -29,6 +30,9 @@ let package = Package(
.product(name: "CmuxSettings", package: "CmuxSettings"),
"CmuxControlSocketAtomicsC",
],
resources: [
.process("Resources/Localizable.xcstrings"),
],
swiftSettings: [
.swiftLanguageMode(.v6),
.enableUpcomingFeature("ExistentialAny"),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
internal import Foundation

/// The system/misc domain (`system.identify`, `system.tree`, `auth.login`,
/// `session.restore_previous`, `settings.open`, `feedback.open`,
/// `session.restore_previous`, `session.import`, `session.export`,
/// `settings.open`, `feedback.open`,
/// `extension.sidebar.snapshot`, `workspace.action`, `surface.action` /
/// `tab.action`, `surface.drag_to_split` / `surface.split_off`, and the
/// DEBUG-only `mobile.dev_stack_auth.configure`), lifted byte-faithfully from
Expand Down Expand Up @@ -30,6 +31,10 @@ extension ControlCommandCoordinator {
return authLogin()
case "session.restore_previous":
return sessionRestorePrevious()
case "session.import":
return sessionImport(request.params)
case "session.export":
return sessionExport(request.params)
case "settings.open":
return settingsOpen(request.params)
case "feedback.open":
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
internal import Foundation

/// The small system-domain bodies: `auth.login`, `session.restore_previous`,
/// `session.import`, `session.export`,
/// `settings.open`, `feedback.open`, `extension.sidebar.snapshot`, the
/// `surface.split_off` / `surface.drag_to_split` bridge, and the DEBUG-only
/// `mobile.dev_stack_auth.configure`.
Expand All @@ -26,6 +27,120 @@ extension ControlCommandCoordinator {
}
}

/// `session.import` — reopen another install's saved session (`source`:
/// a channel name or bundle identifier) or a snapshot file (`path`, an
/// absolute path) as additional windows.
func sessionImport(_ params: [String: JSONValue]) -> ControlCallResult {
let path = string(params, "path")
let channel = string(params, "source")
let source: ControlSessionImportSource
switch (path, channel) {
case (let path?, nil):
guard path.hasPrefix("/") else {
return .err(
code: "invalid_params",
message: String(
format: String(
localized: "socket.sessionTransfer.absolutePathRequired",
defaultValue: "%@ must be an absolute path.",
bundle: .module
),
"session.import params.path"
),
data: .object(["path": .string(path)])
)
}
source = .file(path: path)
case (nil, let channel?):
source = .channel(channel)
default:
return .err(
code: "invalid_params",
message: String(
localized: "socket.sessionTransfer.importSelectorRequired",
defaultValue: "session.import requires exactly one of params.source or params.path.",
bundle: .module
),
data: nil
)
}
guard let systemContext else {
return .err(
code: "unavailable",
message: String(
localized: "socket.sessionTransfer.contextUnavailable",
defaultValue: "Session context is unavailable.",
bundle: .module
),
data: nil
)
}
switch systemContext.controlSessionImport(source: source) {
case let .restored(sourcePath, windowCount, heldBackResumeCount, droppedRemoteWorkspaceCount):
return .ok(.object([
"restored": .bool(true),
"source_path": .string(sourcePath),
"window_count": .int(Int64(windowCount)),
"trusted": .bool(source.isTrusted),
"held_back_resume_count": .int(Int64(heldBackResumeCount)),
"dropped_remote_workspace_count": .int(Int64(droppedRemoteWorkspaceCount)),
]))
case let .failed(code, message, path):
return .err(code: code, message: message, data: .object(["path": orNull(path)]))
}
}

/// `session.export` — write this install's saved session snapshot to an
/// absolute `path`; `force` allows replacing an existing file.
func sessionExport(_ params: [String: JSONValue]) -> ControlCallResult {
guard let path = string(params, "path") else {
return .err(
code: "invalid_params",
message: String(
localized: "socket.sessionTransfer.exportPathRequired",
defaultValue: "session.export requires params.path.",
bundle: .module
),
data: nil
)
}
guard path.hasPrefix("/") else {
return .err(
code: "invalid_params",
message: String(
format: String(
localized: "socket.sessionTransfer.absolutePathRequired",
defaultValue: "%@ must be an absolute path.",
bundle: .module
),
"session.export params.path"
),
data: .object(["path": .string(path)])
)
}
guard let systemContext else {
return .err(
code: "unavailable",
message: String(
localized: "socket.sessionTransfer.contextUnavailable",
defaultValue: "Session context is unavailable.",
bundle: .module
),
data: nil
)
}
switch systemContext.controlSessionExport(path: path, overwrite: bool(params, "force") ?? false) {
case let .exported(exportedPath, sourcePath):
return .ok(.object([
"exported": .bool(true),
"path": .string(exportedPath),
"source_path": .string(sourcePath),
]))
case let .failed(code, message, path):
return .err(code: code, message: message, data: .object(["path": orNull(path)]))
}
}

/// `settings.open` — open the settings window, optionally at a target pane.
func settingsOpen(_ params: [String: JSONValue]) -> ControlCallResult {
let targetRaw = string(params, "target")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/// Where `session.import` reads a snapshot from.
public enum ControlSessionImportSource: Sendable, Equatable {
/// Another install's saved session, named by channel (`stable`,
/// `nightly`, `rc`, `staging`, `debug[:tag]`) or bundle identifier.
case channel(String)
/// A snapshot file at an absolute path.
case file(path: String)

/// Whether the snapshot restores with full trust (automatic resume). Only
/// another install's own session file does; an arbitrary file does not.
public var isTrusted: Bool {
if case .channel = self { return true }
return false
}
}

/// The outcome of `session.import`.
///
/// Failure messages are resolved in the APP conformance so they stay
/// localized; the package only carries the resolved strings.
public enum ControlSessionImportResolution: Sendable, Equatable {
/// The snapshot was validated and reopened as additional windows.
/// `heldBackResumeCount` counts terminals whose resume command was kept
/// for manual restore instead of running automatically (file imports
/// only); `droppedRemoteWorkspaceCount` counts workspaces whose SSH/cloud
/// connection or environment from the file was dropped.
case restored(
sourcePath: String,
windowCount: Int,
heldBackResumeCount: Int,
droppedRemoteWorkspaceCount: Int
)
/// The import failed. `code` is the socket error code (`not_found`,
/// `invalid_params`, `unsupported`, `invalid_state`, `unavailable`),
/// `path` the file involved when known.
case failed(code: String, message: String, path: String?)
}

/// The outcome of `session.export`.
public enum ControlSessionExportResolution: Sendable, Equatable {
/// The saved snapshot at `sourcePath` was written to `path`.
case exported(path: String, sourcePath: String)
/// The export failed. `code` is the socket error code (`not_found`,
/// `already_exists`, `invalid_params`, `invalid_state`, `unavailable`),
/// `path` the file involved when known.
case failed(code: String, message: String, path: String?)
}
Loading