Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
170 changes: 167 additions & 3 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8694,10 +8694,56 @@ 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")
// `--export --force` must not write a file named `--force`, and
// `--from --export x` must not treat `--export` as a channel.
for (flag, value) in [("--from", fromValue), ("--export", exportValue)] {
if let value, value.hasPrefix("--") {
throw CLIError(message: "restore-session: \(flag) requires a value")
}
}
let beforeTerminator = afterExport.prefix { $0 != "--" }
let force = beforeTerminator.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
}

let initialClient = SocketClient(path: socketPath)
let client: SocketClient
Expand Down Expand Up @@ -8729,6 +8775,112 @@ 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`. A channel name or
/// bundle id (`source`) wins over a same-named file in the current
/// directory, so `--from nightly` never silently becomes an untrusted
/// file import; `./nightly` names the file. Any other bare name is a file
/// when it exists, otherwise a source the app resolves.
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 explicitPath = value.contains("/")
|| value.hasPrefix("~")
|| value.hasPrefix(".")
|| value.lowercased().hasSuffix(".json")
if explicitPath {
return ["path": resolvedPath]
}
if Self.restoreSessionLooksLikeChannel(value) {
return ["source": value]
}
if FileManager.default.fileExists(atPath: resolvedPath) {
return ["path": resolvedPath]
}
return ["source": value]
}

/// Whether `value` names an install channel (`stable`, `release`,
/// `nightly`, `rc`, `staging`, `debug`, `debug:<tag>`, `dev:<tag>`) or a
/// cmux bundle identifier. Mirrors `SessionSnapshotFileLocation`.
static func restoreSessionLooksLikeChannel(_ value: String) -> Bool {
let lowered = value.lowercased()
if ["stable", "release", "nightly", "rc", "staging", "debug"].contains(lowered) {
return true
}
if lowered.hasPrefix("debug:") || lowered.hasPrefix("dev:") {
return true
}
return value == "com.cmuxterm.app" || value.hasPrefix("com.cmuxterm.app.")
}

/// 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 @@ -18638,14 +18790,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
Loading
Loading