Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
52ded79
test: reproduce remote tmux browser and image preview failures
JackiMa Sep 27, 2026
9527737
fix: open remote tmux links in a shared preview pane
JackiMa Sep 27, 2026
0711f4c
Test embedded CLI package resource lookup
JackiMa Sep 27, 2026
63cfbc3
Expose package resources to the embedded CLI and document restore dia…
JackiMa Sep 27, 2026
26da8ce
Reproduce cancelled recovery for confirmed running agents
JackiMa Sep 27, 2026
769a705
Reconcile confirmed agent recovery with retired automatic bindings
JackiMa Sep 27, 2026
8bc5a87
Reproduce lost remote tmux workspaces on session restore
JackiMa Sep 27, 2026
de1ba04
Persist and reconnect remote tmux workspaces without local relaunches
JackiMa Sep 27, 2026
bb779c9
Honor managed remote connection policy during restored tmux attach
JackiMa Sep 27, 2026
b98e8be
Verify remote terminal previews against real SSH and artifacts
JackiMa Sep 27, 2026
3295787
Reproduce Grok resume failures from new-session launch selectors
JackiMa Sep 27, 2026
99cf3f1
Remove Grok new-session and fork selectors from resume commands
JackiMa Sep 27, 2026
306b558
Reproduce loss of explicit empty Claude tools on resume
JackiMa Sep 27, 2026
683dd39
Preserve empty Claude option values through session resume
JackiMa Sep 27, 2026
98e517d
Reproduce nonretryable admission during transient process census failure
JackiMa Sep 27, 2026
d13a927
Retry restore admission when the process census is temporarily unavai…
JackiMa Sep 27, 2026
8111375
Reproduce Codex resume blocked by a previous process prompt depth
JackiMa Sep 27, 2026
1f100fd
Refresh Codex restore identity after a confirmed process replacement
JackiMa Sep 27, 2026
28fbf76
Cover absolute previews with stale cwd and relative previews after re…
JackiMa Sep 28, 2026
1821c92
Resolve remote file links from absolute paths or the clicked tmux pane
JackiMa Sep 28, 2026
def0ac2
test: reproduce ptmux file reads on a saturated SSH master
JackiMa Sep 28, 2026
10f0189
fix: allow ptmux file reads when SSH session channels are full
JackiMa Sep 28, 2026
6f09450
test: reproduce clipped legacy tmux mirrors with wider clients
JackiMa Sep 28, 2026
558f281
fix: constrain legacy tmux mirrors to the smallest attached client
JackiMa Sep 28, 2026
ff47924
test: cover bound agent restore after process exit
JackiMa Sep 30, 2026
09103d2
fix: restore agents from retained session bindings
JackiMa Sep 30, 2026
30212c7
test: require bound restore to bypass process admission
JackiMa Oct 2, 2026
c7ac3bc
fix: launch bound agents without process restore admission
JackiMa Oct 2, 2026
fac4378
test: require legacy tmux sizing to follow user interaction
JackiMa Oct 9, 2026
0911998
Fix legacy tmux sizing to follow the last active client
JackiMa Oct 9, 2026
dd0ae27
test: cover session-owned tmux sizing interactions
JackiMa Oct 9, 2026
cc9a5fc
Fix session-owned remote tmux sizing handoff
JackiMa Oct 9, 2026
0889142
test: retain sizing fixture command-order diagnostics
JackiMa Oct 9, 2026
4fe03a8
test: isolate closed-window sizing from peer recovery
JackiMa Oct 9, 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
6 changes: 5 additions & 1 deletion CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1846,7 +1846,11 @@ final class ClaudeHookSessionStore {
includeTerminalPromptTurnIds: Bool = true
) -> Bool {
if max(record.activePromptDepth ?? 0, record.activePromptTurnIds?.count ?? 0) > 0 {
return true
// Prompt depth belongs to a process generation. An interrupted
// process (or a ledger-settled Stop) can leave depth behind; do not
// let it suppress the next confirmed process's resume binding.
// Same-process and unverifiable starts still cannot erase a turn.
return !authoritativeSessionStartProcessIsNewer(incomingPID, than: record)
}
let hasCompletedTurnState = normalizeOptional(record.lastPromptTurnId) != nil
|| (includeTerminalPromptTurnIds && !terminalPromptTurnSet(from: record).isEmpty)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,9 @@ extension AgentLaunchSanitizer {

static func variadicValueCanContinue(_ value: String, policy: Policy) -> Bool {
guard policy.scansOptionsPastPositionals else { return true }
return looksLikeGreedyOptionalValue(value)
// An explicit empty argv value can disable tools or clear a list. It is
// distinct from a missing value and must survive capture and replay.
return value.isEmpty || looksLikeGreedyOptionalValue(value)
}

static func looksLikeOptionalValue(_ value: String, following: String?) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ extension AgentLaunchSanitizer {
"-r",
"--rules",
"--sandbox",
"--session-id",
"-s",
"--system-prompt-override",
"--tools",
"--worktree",
Expand All @@ -27,6 +29,9 @@ extension AgentLaunchSanitizer {
optionalValueOptions: [
"--resume",
"-r",
// Older captures dropped the UUID but retained this selector.
"--session-id",
"-s",
"--worktree",
"-w"
],
Expand All @@ -53,15 +58,20 @@ extension AgentLaunchSanitizer {
droppedOptions: [
"--continue",
"-c",
"--fork-session",
"--restore-code",
"--resume",
"-r",
"--session-id",
"-s",
"--worktree",
"-w"
],
droppedOptionPrefixes: [
"--resume=",
"-r=",
"--session-id=",
"-s=",
"--worktree=",
"-w="
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,40 @@ import Testing

@Suite("AgentResumeArgv")
struct AgentResumeArgvTests {
@Test("Claude preserves the explicit empty tools list through capture and resume")
func claudeResumeKeepsEmptyToolList() throws {
let captured = try #require(AgentLaunchSanitizer.sanitizedLaunchArguments(
["claude", "--tools", "", "--model", "sonnet"],
launcher: "claude",
fallbackKind: "claude"
))
#expect(
AgentResumeArgv().builtInKind(
kind: "claude", sessionId: "saved-session", executablePath: nil, arguments: captured
) == ["claude", "--resume", "saved-session", "--tools", "", "--model", "sonnet"]
)
}

@Test("Grok resumes the saved conversation without new-session selectors", arguments: [
["--session-id", "new-session"],
["-s", "new-session"],
["--session-id=new-session"],
["-s=new-session"],
["--resume", "parent-session", "--fork-session", "--session-id", "new-session"],
// Older captures kept the flag but dropped its value as a positional prompt.
["--session-id"],
])
func grokResumeDropsNewSessionSelectors(selectors: [String]) {
#expect(
AgentResumeArgv().builtInKind(
kind: "grok",
sessionId: "saved-session",
executablePath: "/opt/bin/grok",
arguments: ["/opt/bin/grok"] + selectors + ["--model", "grok-4", "--sandbox", "read-only"]
) == ["/opt/bin/grok", "-r", "saved-session", "--model", "grok-4", "--sandbox", "read-only"]
)
}

@Test("Built-in --option style kinds", arguments: [
("claude", "claude", ["claude", "--resume", "SID"]),
("grok", "grok", ["grok", "-r", "SID"]),
Expand Down
17 changes: 12 additions & 5 deletions Sources/DockSplitStore+SessionRestore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,8 @@ extension DockSplitStore {
snapshot,
workspaceId: workspaceId
)
guard let terminalSnapshot = snapshot.terminal else { return nil }
guard let savedTerminalSnapshot = snapshot.terminal else { return nil }
let terminalSnapshot = savedTerminalSnapshot.reconcilingAgentBindingForRestore(surfaceID: snapshot.id)
let policy = Workspace.makeSessionRestorePolicyService()
let localTmuxStartCommand = policy.localTmuxStartCommand(terminalSnapshot.tmuxStartCommand)
let restorableAgent = localTmuxStartCommand == nil
Expand Down Expand Up @@ -286,17 +287,20 @@ extension DockSplitStore {
recoveryObservationsByPanelId[snapshot.id] = recoveryObservation
let recoveryPlan = terminalSnapshot.recoveryPlan(
surfaceID: snapshot.id,
claimedOwnerSurfaceID: recoveryObservation.agent.flatMap { recoveryPlannedAgentOwners[$0] }
claimedOwnerSurfaceID: terminalSnapshot.boundAgentIdentityForRestore.flatMap { recoveryPlannedAgentOwners[$0] }
)
let shouldAutoResumeAgent: Bool = {
guard AgentSessionAutoResumeSettings.isEnabled(defaults: agentSessionAutoResumeDefaults),
case .resumeAgent = recoveryPlan.action else { return false }
return true
}()
if shouldAutoResumeAgent, let agent = recoveryObservation.agent {
if shouldAutoResumeAgent, let agent = terminalSnapshot.boundAgentIdentityForRestore {
recoveryPlannedAgentOwners[agent] = snapshot.id
}
let shouldCheckAgentOwnership = shouldAutoResumeAgent &&
let restoresBoundLocalAgent = shouldAutoResumeAgent &&
terminalSnapshot.hasLocalBoundAgentBindingForRestore &&
terminalSnapshot.isRemoteTerminal != true && hibernation == nil
let shouldCheckAgentOwnership = !restoresBoundLocalAgent && shouldAutoResumeAgent &&
(restorableAgentCanAutoResume || resumeBinding?.isAgentHookBinding == true)
let restoreAgentIndex = shouldCheckAgentOwnership ? restorableAgentIndex : nil
let restoreIndexUnavailable = shouldCheckAgentOwnership && restoreAgentIndex == nil
Expand Down Expand Up @@ -361,7 +365,10 @@ extension DockSplitStore {
stablePanelHasLiveProcess
? nil
: approvedResumeBinding.flatMap {
policy.surfaceResumeStartupLaunch(
if restoresBoundLocalAgent {
return $0.boundAgentStartupLaunch()
}
return policy.surfaceResumeStartupLaunch(
forApprovedBinding: $0
)
}
Expand Down
8 changes: 3 additions & 5 deletions Sources/DockSplitStore+SessionSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -520,11 +520,9 @@ extension DockSplitStore {
currentProcessIdentity: Workspace.agentPIDProcessIdentity(pid:)
)
}
let coordinated = restoredAgentLifecycle.continuationSnapshot(
panelId: panelId,
observation: observation,
currentProcessIdentity: Workspace.agentPIDProcessIdentity(pid:)
)
// Completion ends the old process; the panel keeps its session binding
// until another command or an explicit mutation replaces it.
let coordinated = restoredAgentLifecycle.snapshotsByPanelId[panelId]
let observed = restoredAgentLifecycle.resumeStatesByPanelId[panelId] == .completedAgentExit
? nil
: observation?.snapshot
Expand Down
5 changes: 2 additions & 3 deletions Sources/MainWindowPersistenceRouteSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,14 @@ enum MainWindowPersistenceRouteSnapshot {
}
}

/// Remote-tmux-only live windows require their active SSH control graph
/// and intentionally do not participate in persisted session topology.
/// Mirrors with a saved attachment target participate in session persistence.
var isEligibleForSessionPersistence: Bool {
switch self {
case .live(let route):
let workspaces = route.tabManager.tabs
let omitsRemoteMirrorOnlyWindow = route.dock == nil
&& !workspaces.isEmpty
&& workspaces.allSatisfy(\.isRemoteTmuxMirror)
&& workspaces.allSatisfy { $0.isRemoteTmuxMirror && !$0.isRestorableInSessionSnapshot }
return !omitsRemoteMirrorOnlyWindow
case .frozen:
return true
Expand Down
16 changes: 13 additions & 3 deletions Sources/RemoteTmuxControlConnection+Commands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,14 @@ extension RemoteTmuxControlConnection {
// while a smaller peer legitimately clamped the window must not suppress
// recovery after that peer leaves.
windowClaimParityRearmsSpent.removeAll()
if !supportsPerWindowSize {
_ = send("set-option -g focus-events on")
_ = send("refresh-client -f ignore-size")
legacyWindowSizingConfigured.removeAll()
for windowId in lastWindowSizes.keys.sorted() {
configureLegacyWindowSizing(windowId: windowId)
}
}
if let size = lastClientSize {
if send("refresh-client -C \(size.columns)x\(size.rows)") {
lastSentClientSize = size
Expand Down Expand Up @@ -448,17 +456,19 @@ extension RemoteTmuxControlConnection {
/// Sends literal key bytes to a pane via tmux `send-keys -H` (hex-encoded),
/// which is binary-safe and needs no shell quoting.
@discardableResult
func sendKeys(paneId: Int, data: Data) -> Bool {
func sendKeys(paneId: Int, data: Data, claimsSizing: Bool = true) -> Bool {
guard !data.isEmpty else { return true }
if claimsSizing, !activatePaneSizing(paneId: paneId) { return false }
let hex = Self.hexByteArguments(data)
return sendInternal("send-keys -t %\(paneId) -H \(hex)", kind: .other)
}

/// Sends a physical named key and lets tmux encode it for the target pane's
/// advertised terminal and live input modes.
@discardableResult
func sendKey(paneId: Int, key: RemoteTmuxKeyName) -> Bool {
sendInternal("send-keys -t %\(paneId) \(key.value)", kind: .other)
func sendKey(paneId: Int, key: RemoteTmuxKeyName, claimsSizing: Bool = true) -> Bool {
if claimsSizing, !activatePaneSizing(paneId: paneId) { return false }
return sendInternal("send-keys -t %\(paneId) \(key.value)", kind: .other)
}

nonisolated static func hexByteArguments(_ data: Data) -> String {
Expand Down
104 changes: 96 additions & 8 deletions Sources/RemoteTmuxControlConnection+Sizing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ extension RemoteTmuxControlConnection {

func removeWindowSizeClaim(windowId: Int) {
windowClaimParityRearmsSpent.removeValue(forKey: windowId)
legacyWindowSizingConfigured.remove(windowId)
if interactiveSizingWindowId == windowId {
interactiveSizingWindowId = nil
interactiveSizingOwnerId = nil
}
guard let removed = lastWindowSizes.removeValue(forKey: windowId) else {
sentWindowSizes.removeValue(forKey: windowId)
return
Expand All @@ -46,6 +51,11 @@ extension RemoteTmuxControlConnection {
func retainWindowSizeClaims(for liveWindowIDs: Set<Int>) {
lastWindowSizes = lastWindowSizes.filter { liveWindowIDs.contains($0.key) }
sentWindowSizes = sentWindowSizes.filter { liveWindowIDs.contains($0.key) }
legacyWindowSizingConfigured.formIntersection(liveWindowIDs)
if let id = interactiveSizingWindowId, !liveWindowIDs.contains(id) {
interactiveSizingWindowId = nil
interactiveSizingOwnerId = nil
}
windowClaimParityRearmsSpent = windowClaimParityRearmsSpent.filter { liveWindowIDs.contains($0.key) }
maximumWindowClaimColumns = lastWindowSizes.values.reduce(0) { max($0, $1.0) }
maximumWindowClaimRows = lastWindowSizes.values.reduce(0) { max($0, $1.1) }
Expand Down Expand Up @@ -138,10 +148,10 @@ extension RemoteTmuxControlConnection {
/// ``reseedAfterReconnect()`` re-pins every window (a fresh ssh client
/// otherwise reverts everything to 80×24).
///
/// If the server rejects the `@id:` form (`%error` — pre-3.x tmux), the
/// connection flips to the session-wide fallback for its lifetime and
/// surfaces the degraded mode in diagnostics; callers keep calling this
/// method either way.
/// If the server rejects the `@id:` form (including tmux 3.2a), the
/// connection uses a session-wide client size and configures mirrored
/// windows to follow the last interacting client. Geometry records dimensions;
/// explicit focus/input transfers ownership through `activateWindowSizing`.
func setWindowSize(windowId: Int, columns: Int, rows: Int) {
guard columns > 0, rows > 0 else { return }
// Record desired state before dedup. If a different debounced size is
Expand All @@ -150,7 +160,10 @@ extension RemoteTmuxControlConnection {
_ = recordWindowSizeClaim(windowId: windowId, columns: columns, rows: rows)
lastSizeRequestWindowId = windowId
guard supportsPerWindowSize else {
setClientSize(columns: columns, rows: rows)
configureLegacyWindowSizing(windowId: windowId)
if interactiveSizingWindowId == nil || interactiveSizingWindowId == windowId {
setClientSize(columns: columns, rows: rows)
}
return
}
// The CLIENT's own size must cover the largest window claim: tmux
Expand Down Expand Up @@ -269,6 +282,7 @@ extension RemoteTmuxControlConnection {
/// Sends the per-window form, tagging the command so an `%error` reply
/// can flip the capability off and replay via the session-wide path.
func sendPerWindowSize(windowId: Int, columns: Int, rows: Int) {
guard supportsPerWindowSize else { return }
// Record AFTER the send reports success: a send attempted while the
// transport is down returns false, and recording it anyway makes
// the ledger claim the server has a size it never received — dedup
Expand All @@ -289,16 +303,90 @@ extension RemoteTmuxControlConnection {
func notePerWindowSizeRejected() {
guard supportsPerWindowSize else { return }
supportsPerWindowSize = false
record("remote.tmux.perWindowSize unsupported; falling back to session-wide client size")
for task in windowSizeDebounceTasks.values { task.cancel() }
windowSizeDebounceTasks.removeAll()
for task in perWindowRedrawKickTasks.values { task.cancel() }
perWindowRedrawKickTasks.removeAll()
sentWindowSizes.removeAll()
_ = send("set-option -g focus-events on")
_ = send("refresh-client -f ignore-size")
record("remote.tmux.perWindowSize unsupported; using latest interaction with session-wide client size")
for windowId in lastWindowSizes.keys.sorted() {
configureLegacyWindowSizing(windowId: windowId)
}
// Replay the most recently requested window's size — deterministic,
// and in practice the visible tab's. (`.values.first` on a Dictionary
// could hand the session a hidden tab's stale claim.)
let replay = lastSizeRequestWindowId.flatMap { lastWindowSizes[$0] } ?? lastWindowSizes.values.first
if let replay {
setClientSize(columns: replay.0, rows: replay.1)
}
if let windowId = interactiveSizingWindowId {
_ = activateWindowSizing(windowId: windowId, ownerId: interactiveSizingOwnerId)
}
}

/// Removes the previous compatibility policy. Latest keeps a background
/// cmux connection from permanently limiting a native terminal's grid.
func configureLegacyWindowSizing(windowId: Int) {
guard !supportsPerWindowSize,
connectionState == .connected,
!legacyWindowSizingConfigured.contains(windowId) else { return }
guard sendTracked(
"set-option -w -t @\(windowId) window-size latest",
completion: { [weak self] succeeded in
if !succeeded { self?.legacyWindowSizingConfigured.remove(windowId) }
}
) else { return }
legacyWindowSizingConfigured.insert(windowId)
lastSentClientSize = nil
}

/// Only explicit user focus/input calls this. In tmux 3.2a neither
/// refresh-client -C nor send-keys updates w->latest. select-window does,
/// then the final refresh commits its otherwise deferred resize immediately.
/// Native terminal input/resize can subsequently take ownership naturally.
@discardableResult
func activateWindowSizing(windowId: Int, ownerId: UUID? = nil) -> Bool {
interactiveSizingWindowId = windowId
interactiveSizingOwnerId = ownerId
guard !supportsPerWindowSize else { return true }
guard let size = lastWindowSizes[windowId] else { return true }
guard connectionState == .connected else { return false }
let sessionTarget = sessionId.map { "'$\($0)'" }
?? RemoteTmuxHost.controlModeLineSafeName(sessionName)
.map { RemoteTmuxHost.shellSingleQuoted("=" + $0) }
guard let sessionTarget else { return false }
clientSizeDebounceTask?.cancel()
clientSizeDebounceTask = nil
let commands = [
"refresh-client -f '!ignore-size'",
"set-option -w -t @\(windowId) window-size latest",
"select-window -t \(sessionTarget):@\(windowId)",
"refresh-client -C \(size.0)x\(size.1)"
]
guard sendBatchInternal(commands, kinds: commands.map { _ in .other }) else { return false }
lastClientSize = (size.0, size.1)
lastSentClientSize = lastClientSize
scheduleAttachRedrawKickIfNeeded()
return true
}

func deactivateWindowSizing(windowId: Int, ownerId: UUID? = nil) {
guard interactiveSizingWindowId == windowId,
ownerId == nil || ownerId == interactiveSizingOwnerId else { return }
interactiveSizingWindowId = nil
interactiveSizingOwnerId = nil
if !supportsPerWindowSize { _ = send("refresh-client -f ignore-size") }
}

func activatePaneSizing(paneId: Int) -> Bool {
guard !supportsPerWindowSize else { return true }
guard let windowId = publishedWindowIdByPane[paneId]
?? windowsByID.values.first(where: { $0.paneIDsInOrder.contains(paneId) })?.id
else { return false }
return activateWindowSizing(windowId: windowId)
}

/// One-shot, attach-only: force a real SIGWINCH when the attach size push was a
/// no-op, so a running TUI re-renders the current frame at the current width.
Expand Down Expand Up @@ -354,8 +442,8 @@ extension RemoteTmuxControlConnection {
return
} else {
// Only kick when some mirrored window ALREADY has the target size — i.e. the
// size apply above cannot produce a SIGWINCH for it. (window-size latest makes
// every window track the client, so one client-level kick redraws them all.)
// size apply above cannot produce a SIGWINCH for it. A client-level
// kick redraws the windows whose size is bounded by this client.
let windowAlreadyAtTarget = windowsByID.values.contains {
Self.windowMatchesClaim(
windowColumns: $0.width, windowRows: $0.height,
Expand Down
5 changes: 5 additions & 0 deletions Sources/RemoteTmuxControlConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,11 @@ final class RemoteTmuxControlConnection {
var windowSizeDebounceTasks: [Int: Task<Void, Never>] = [:]
/// Whether the server accepts per-window `refresh-client -C` sizing.
var supportsPerWindowSize = true
/// Mirrored legacy windows migrated from the old smallest policy to latest.
var legacyWindowSizingConfigured: Set<Int> = []
/// Last window explicitly activated by the user, never by a layout echo.
var interactiveSizingWindowId: Int?
var interactiveSizingOwnerId: UUID?
/// Instant of the most recent sizing write on this connection — kept for
/// diagnostics (how stale is the last size request).
var lastSizingSendAt: ContinuousClock.Instant?
Expand Down
Loading
Loading