Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions Sources/ControlSurfaceResumeTarget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,11 @@ extension TerminalController {
guard let binding else { return nil }
let trimmedKind = binding.kind?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedKind = trimmedKind.flatMap { $0.isEmpty ? nil : $0 } ?? "command"
// Remote-synthesized bindings (#7989) intentionally map to `.direct`:
// they carry no session checkpoint, so `cmux surface resume show`
// presents the stored directory-level continue command verbatim under
// the agent kind instead of a typed `cmux restore <kind> <checkpoint>`
// selector.
let mode: AgentRestoreRequestMode = binding.isAgentHookBinding
? .resumeAgent
: .direct
Expand Down
87 changes: 87 additions & 0 deletions Sources/RemoteAgentContinueSynthesizer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Foundation

/// Synthesizes a hookless, directory-scoped resume binding for a remote agent
/// (issue #7989, Tier 1).
///
/// A remote host without cmux agent hooks never reports a session checkpoint,
/// so the strongest honest restore signal is "agent `<kind>` was running in
/// `<directory>`". This synthesizer turns that pair into a conservative
/// `cd <dir> && <agent> --continue || <agent>` command bound to the panel's
/// persistent-SSH PTY, mirroring how `TmuxResumeParser` turns a locally
/// observed tmux client into a `process-detected` binding.
///
/// Known limitation (accepted for hookless remotes): a directory-scoped
/// continue command resumes whatever conversation the agent considers most
/// recent for that directory. When several sessions of the same agent share
/// one working directory, the wrong conversation can be continued.
enum RemoteAgentContinueSynthesizer {
/// `SurfaceResumeBindingSnapshot.source` value for synthesized bindings;
/// must match `SurfaceResumeBindingSnapshot.isRemoteSynthesized`.
static let source = "remote-synthesized"

/// Builds a directory-scoped continue binding, or nil when the agent kind
/// has no trustworthy sessionless continue invocation or the remote
/// working directory is unknown.
static func binding(
kind: RestorableAgentKind,
remoteWorkingDirectory: String?,
remoteContext: SurfaceResumeRemoteContext,
updatedAt: TimeInterval = Date().timeIntervalSince1970
) -> SurfaceResumeBindingSnapshot? {
guard let workingDirectory = normalized(remoteWorkingDirectory),
let continueCommand = directoryScopedContinueCommand(for: kind) else {
return nil
}
// Same cd guard as every other startup command
// (`TerminalStartupWorkingDirectoryPrefix`): tolerate a deleted saved
// directory instead of failing before the agent launches. The command
// runs on the remote host, so the local claude/codex wrapper-resolver
// tokens are deliberately not used here — mirroring
// `SurfaceResumeBindingSnapshot.remoteStartupInput()`, which renders
// remote commands with `repairPortableAgentExecutable: false`.
let command = TerminalStartupWorkingDirectoryPrefix.prefix(
continueCommand,
workingDirectory: workingDirectory
)
return SurfaceResumeBindingSnapshot(
name: "\(kind.displayName) continue",
kind: kind.rawValue,
command: command,
cwd: workingDirectory,
source: source,
autoResume: true,
launchFlavor: .persistentSSH(remoteContext),
updatedAt: updatedAt
)
}

/// Sessionless continue templates. Deliberately conservative: only agents
/// with a documented directory-level continue invocation are covered; the
/// per-session commands in `docs/agent-hooks.md` all need a checkpoint id
/// that a hookless remote cannot provide. The `|| <agent>` fallback starts
/// a fresh session when the agent has nothing to continue in that
/// directory, so restore never dead-ends on a continue error.
private static func directoryScopedContinueCommand(
for kind: RestorableAgentKind
) -> String? {
switch kind {
case .claude:
// Continues the most recent conversation recorded for the current
// working directory (claude's session store is cwd-keyed).
return "claude --continue || claude"
case .codex:
// Resumes the most recently used codex session.
return "codex resume --last || codex"
default:
return nil
}
}

private static func normalized(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else {
return nil
}
return trimmed
}
}
6 changes: 6 additions & 0 deletions Sources/SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,12 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable {
source == "cli"
}

/// A directory-scoped continue binding synthesized by
/// `RemoteAgentContinueSynthesizer` for a hookless remote agent (#7989).
var isRemoteSynthesized: Bool {
source == "remote-synthesized"
}

var allowsAutomaticResume: Bool {
autoResume == true
}
Expand Down
9 changes: 8 additions & 1 deletion Sources/SurfaceResumeApprovalSigningSecretCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,14 @@ extension SurfaceResumeApprovalStore {
private static func trustedBinding(
from binding: SurfaceResumeBindingSnapshot
) -> SurfaceResumeBindingSnapshot? {
if binding.isProcessDetected {
if binding.isProcessDetected || binding.isRemoteSynthesized {
// Both sources are cmux's own observations rather than proposals
// from an arbitrary process: process-detected comes from scanning
// live local processes, and remote-synthesized commands are built
// exclusively from `RemoteAgentContinueSynthesizer`'s inline
// directory-level continue templates (no caller-supplied
// arguments), so they share the same trust tier and bypass the
// signed approval store.
var trustedBinding = binding
trustedBinding.autoResume = true
trustedBinding.approvalPolicy = .auto
Expand Down
10 changes: 9 additions & 1 deletion Sources/Workspace+PersistentRemotePTYReattach.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ extension Workspace {
panelID: panelId,
persistentPTYSessionID: sessionID
)
// A synthesized directory-level continue command (#7989) has no
// session checkpoint the remote once-guard could reconcile
// against, so only inject it once the PTY is confirmed ended;
// a live PTY is attach-only.
let injectableResumeCommand =
resumeBinding?.isRemoteSynthesized == true && !sessionEnded
? nil
: approvedResumeCommand
let restartedShellCommand = sessionEnded
? configuration.relayPort.map {
SSHPTYAttachStartupCommandBuilder.restoredRemoteShellCommand(
Expand All @@ -73,7 +81,7 @@ extension Workspace {
: nil
command = remotePTYAttachStartupCommand(
sessionID: sessionID,
remoteCommand: approvedResumeCommand ?? restartedShellCommand,
remoteCommand: injectableResumeCommand ?? restartedShellCommand,
requireExisting: !sessionEnded
)
} else {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Workspace+RemoteSurfaceResumeBinding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ extension Workspace {
) else {
return nil
}
if effectiveBinding.isAgentHookBinding,
if effectiveBinding.isAgentHookBinding || effectiveBinding.isRemoteSynthesized,
!AgentSessionAutoResumeSettings.isEnabled(defaults: agentSessionAutoResumeDefaults) {
return nil
}
Expand Down
40 changes: 37 additions & 3 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1411,13 +1411,46 @@ extension Workspace {
persistentPTYSessionID: restoredRemotePTYSessionID,
restoresRemoteTerminal: restoresRemoteWorkspaceTerminalSnapshot
)
// Tier-1 hookless remote resume (#7989): a remote agent without
// relayed hooks leaves no resume binding, so a persistent-SSH
// restore reattaches the PTY but never resumes the agent when the
// PTY is gone. When the snapshot still knows the agent kind and
// remote working directory, synthesize a directory-scoped
// continue binding. An existing binding (agent-hook / cli /
// process-detected) always wins; the synthesized one only fills
// the gap.
let synthesizedRemoteContinueBinding: SurfaceResumeBindingSnapshot? = {
guard locatedResumeBinding == nil,
restoresRemoteWorkspaceTerminalSnapshot,
shouldAutoResumeAgent,
let restoredRemotePTYSessionID,
let restorableAgent else {
return nil
}
return RemoteAgentContinueSynthesizer.binding(
kind: restorableAgent.kind,
remoteWorkingDirectory: restorableAgent.workingDirectory
?? restorableAgent.launchCommand?.workingDirectory
?? (restoresUntrustedSavedDirectory ? nil : snapshot.terminal?.workingDirectory),
remoteContext: SurfaceResumeRemoteContext(
workspaceID: restoredResumeSnapshotWorkspaceID,
surfaceID: snapshot.id,
persistentPTYSessionID: restoredRemotePTYSessionID
)
)
}()
let resumeBinding = Self.resumeBindingForSessionRestore(
locatedResumeBinding,
locatedResumeBinding ?? synthesizedRemoteContinueBinding,
restorableAgent: restorableAgent
)
let resumeBindingForStartup =
restoredHibernation != nil ||
(resumeBinding?.isProcessDetected == true && resumeBinding?.autoResume != true)
(resumeBinding?.isProcessDetected == true && resumeBinding?.autoResume != true) ||
// A synthesized continue binding is only as fresh as the
// wasAgentRunning evidence captured with the snapshot; once
// the remote agent exited (or auto-resume is disabled), a
// previously persisted synthesized binding must not replay.
(resumeBinding?.isRemoteSynthesized == true && !shouldAutoResumeAgent)
? nil
: resumeBinding
let effectiveResumeBindingForStartup = sessionRestorePolicy.approvedSurfaceResumeBinding(
Expand Down Expand Up @@ -1582,7 +1615,8 @@ extension Workspace {
localWorkingDirectory ?? hostShellWorkingDirectory
let restoredAgentWillRunStartupCommand =
restoredPersistentSSHResumeCommand != nil &&
resumeBinding?.isAgentHookBinding == true
(resumeBinding?.isAgentHookBinding == true ||
resumeBinding?.isRemoteSynthesized == true)
let restoredAgentWillRunStartupInput =
restoredAgentResumeLaunch?.initialInput != nil ||
(restoredBindingLaunch?.initialInput != nil && resumeBinding?.isAgentHookBinding == true)
Expand Down
8 changes: 8 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1881,6 +1881,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
A74770010000000000000009 /* SessionPersistencePolicy+ConfigFrames.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7477001000000000000000A /* SessionPersistencePolicy+ConfigFrames.swift */; };
C65930020000000000000002 /* SessionPersistencePolicy+CrashStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65930020000000000000001 /* SessionPersistencePolicy+CrashStorage.swift */; };
F6572002A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572003A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift */; };
9DCE2D1C84A5FB5D00BF4437 /* RemoteAgentContinueSynthesizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72D87115779C1B5E4B9ADB2C /* RemoteAgentContinueSynthesizerTests.swift */; };
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; };
812600000000000000000003 /* SessionRemoteWorkspaceMoshRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 812600000000000000000004 /* SessionRemoteWorkspaceMoshRestoreTests.swift */; };
E30780000000000000000014 /* SessionRemoteWorkspaceSnapshot+Restore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30780000000000000000013 /* SessionRemoteWorkspaceSnapshot+Restore.swift */; };
Expand Down Expand Up @@ -2186,6 +2187,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
842300000000000000000001 /* SurfaceResumeExitedAgentLivenessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842300000000000000000002 /* SurfaceResumeExitedAgentLivenessTests.swift */; };
7989B0027989B0027989B002 /* SurfaceResumeLaunchFlavor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7989B1027989B1027989B102 /* SurfaceResumeLaunchFlavor.swift */; };
7989B0037989B0037989B003 /* SurfaceResumeRemoteContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7989B1037989B1037989B103 /* SurfaceResumeRemoteContext.swift */; };
55385DA3BD1729F18132159B /* RemoteAgentContinueSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 447566488217DA11300AADDE /* RemoteAgentContinueSynthesizer.swift */; };
F27B00000000000000000001 /* SurfaceResumeRunPromptBatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = F27B00000000000000000002 /* SurfaceResumeRunPromptBatch.swift */; };
A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; };
C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */; };
Expand Down Expand Up @@ -4632,6 +4634,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
A7477001000000000000000A /* SessionPersistencePolicy+ConfigFrames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionPersistencePolicy+ConfigFrames.swift"; sourceTree = "<group>"; };
C65930020000000000000001 /* SessionPersistencePolicy+CrashStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionPersistencePolicy+CrashStorage.swift"; sourceTree = "<group>"; };
F6572003A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceResumeBindingTests.swift; sourceTree = "<group>"; };
72D87115779C1B5E4B9ADB2C /* RemoteAgentContinueSynthesizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAgentContinueSynthesizerTests.swift; sourceTree = "<group>"; };
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; };
812600000000000000000004 /* SessionRemoteWorkspaceMoshRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRemoteWorkspaceMoshRestoreTests.swift; sourceTree = "<group>"; };
E30780000000000000000013 /* SessionRemoteWorkspaceSnapshot+Restore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionRemoteWorkspaceSnapshot+Restore.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4930,6 +4933,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
842300000000000000000002 /* SurfaceResumeExitedAgentLivenessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeExitedAgentLivenessTests.swift; sourceTree = "<group>"; };
7989B1027989B1027989B102 /* SurfaceResumeLaunchFlavor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeLaunchFlavor.swift; sourceTree = "<group>"; };
7989B1037989B1037989B103 /* SurfaceResumeRemoteContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeRemoteContext.swift; sourceTree = "<group>"; };
447566488217DA11300AADDE /* RemoteAgentContinueSynthesizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAgentContinueSynthesizer.swift; sourceTree = "<group>"; };
F27B00000000000000000002 /* SurfaceResumeRunPromptBatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeRunPromptBatch.swift; sourceTree = "<group>"; };
A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = "<group>"; };
C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceTabBarButtonConfiguration.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -7259,6 +7263,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
7989B1017989B1017989B101 /* SurfaceResumeBindingSnapshot+Remote.swift */,
7989B1027989B1027989B102 /* SurfaceResumeLaunchFlavor.swift */,
7989B1037989B1037989B103 /* SurfaceResumeRemoteContext.swift */,
447566488217DA11300AADDE /* RemoteAgentContinueSynthesizer.swift */,
A74770010000000000000006 /* SessionConfigFrameEntry.swift */,
A74770010000000000000008 /* SessionConfigFrameRing.swift */,
A74770010000000000000004 /* SessionDisplaySnapshot.swift */,
Expand Down Expand Up @@ -7747,6 +7752,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
F83620010000000000000002 /* PortScannerTTYFreshnessTests.swift */,
B79500320000000000000002 /* PortScannerIdentityContinuityTests.swift */,
F6572003A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift */,
72D87115779C1B5E4B9ADB2C /* RemoteAgentContinueSynthesizerTests.swift */,
7989A0027989A0027989A002 /* RemoteResumeBindingTests.swift */,
812600000000000000000004 /* SessionRemoteWorkspaceMoshRestoreTests.swift */,
F6572013A1B2C3D4E5F60718 /* SurfaceResumeBindingCodexUpdateCheckTests.swift */,
Expand Down Expand Up @@ -10214,6 +10220,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
F6572000A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift in Sources */,
7989B0027989B0027989B002 /* SurfaceResumeLaunchFlavor.swift in Sources */,
7989B0037989B0037989B003 /* SurfaceResumeRemoteContext.swift in Sources */,
55385DA3BD1729F18132159B /* RemoteAgentContinueSynthesizer.swift in Sources */,
F27B00000000000000000001 /* SurfaceResumeRunPromptBatch.swift in Sources */,
A5001303 /* SurfaceSearchOverlay.swift in Sources */,
C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */,
Expand Down Expand Up @@ -11372,6 +11379,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
850000000000000000000002 /* SessionIndexTableViewportTests.swift in Sources */,
8A3392FE64E0605D942213D1 /* SessionIndexViewTests.swift in Sources */,
F6572002A1B2C3D4E5F60718 /* SessionPersistenceResumeBindingTests.swift in Sources */,
9DCE2D1C84A5FB5D00BF4437 /* RemoteAgentContinueSynthesizerTests.swift in Sources */,
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
812600000000000000000003 /* SessionRemoteWorkspaceMoshRestoreTests.swift in Sources */,
806600000000000000000002 /* SessionRestorableAgentSnapshotPermissionModeTests.swift in Sources */,
Expand Down
Loading