Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
### Changed
- Each workspace row now shows one agent indicator (Needs input, Working, or Idle) instead of the badge, status row, and notification text sometimes disagreeing with each other. It dims and shows "(stale)" after ten minutes with no update from the agent, and only clears when the agent itself reports it's resumed, not just from opening the workspace.

### Fixed
- A window closed with the red button no longer comes back on the next launch. Since 0.5.0 a closed window was kept alive for Dock reopen and then saved and restored like a visible one, so every restart opened an extra window of stale workspaces. Closed windows now stay closed across a relaunch and their shells are ended at startup.

## [0.5.0] - 2026-09-16

### Removed
Expand Down
18 changes: 15 additions & 3 deletions Sources/AppDelegate+SessionSnapshotPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,16 @@ extension AppDelegate {
}

func buildSessionSnapshot(includeScrollback: Bool, cleanShutdown: Bool = false) -> AppSessionSnapshot? {
// Hidden windows (closed by the user, kept alive by `preserveMainWindowOnClose`) sort
// last so `windows.first` -- the entry restore applies to the launch window -- is
// always one the user could see. They are still written, flagged `isHidden`, so the
// next launch knows which escrowed shells to end instead of reviving.
let contexts = mainWindowContexts.values.sorted { lhs, rhs in
let lhsIsHidden = lhs.hiddenWindow != nil
let rhsIsHidden = rhs.hiddenWindow != nil
if lhsIsHidden != rhsIsHidden {
return !lhsIsHidden
}
let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId)
let rhsWindow = rhs.window ?? windowForMainWindowId(rhs.windowId)
let lhsIsKey = lhsWindow?.isKeyWindow ?? false
Expand All @@ -204,15 +213,18 @@ extension AppDelegate {
.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot)
.map { context in
let window = context.window ?? windowForMainWindowId(context.windowId)
let isHidden = context.hiddenWindow != nil
return SessionWindowSnapshot(
frame: window.map { SessionRectSnapshot($0.frame) },
display: displaySnapshot(for: window),
tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback),
// A hidden window is never shown again, so its scrollback is dead weight.
tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback && !isHidden),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The isHidden: isHidden ? true : nil pattern is intentional for Codable optional encoding (nil omits the key, true writes it), but the computed property isHiddenWindow already handles nil-as-false. Consider adding a brief comment explaining why true : nil is used instead of just isHidden, since the ternary with identical values looks like a no-op at first glance.

sidebar: SessionSidebarSnapshot(
isVisible: context.sidebarState.isVisible,
selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection),
width: SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth))
)
),
isHidden: isHidden ? true : nil
)
}

Expand Down Expand Up @@ -241,7 +253,7 @@ extension AppDelegate {
"session.save.window idx=\(index) " +
"frame={\(debugSessionRectDescription(windowSnapshot.frame))} " +
"display={\(debugSessionDisplayDescription(windowSnapshot.display))} " +
"workspaces=\(workspaceCount) selected=\(selectedWorkspace)"
"workspaces=\(workspaceCount) selected=\(selectedWorkspace) hidden=\(windowSnapshot.isHiddenWindow ? 1 : 0)"
)
}
}
Expand Down
69 changes: 61 additions & 8 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
private var startupSessionSnapshot: AppSessionSnapshot?
private var didPrepareStartupSessionSnapshot = false
private var didAttemptStartupSessionRestore = false
/// Session ids whose shells `endShellsOfHiddenWindows` ended (or tried to) this launch.
/// `reconcileOrphanedEscrowedSessions` skips them: their WAL directory removal is
/// asynchronous, and a session the holder refused to hand over must not be revived either.
private var startupEndedHiddenSessionIds = Set<String>()
var isApplyingStartupSessionRestore = false
lazy var startupHandoff = StartupSessionHandoff(
olderProcess: StartupSessionHandoff.authenticatedOlderProcess,
Expand Down Expand Up @@ -1786,12 +1790,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
// sessions silently. Say what happened.
notifyUncleanShutdownRecovery()
}
let primaryWindowSnapshot = startupSnapshot?.windows.first
// Windows the user had closed before quitting are not shown again: their escrowed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The windowsToRestore array is computed with optional binding on startupSnapshot, but then startupSnapshot is force-unwrapped in the if let startupSnapshot block on line 1798. The double optional binding is redundant—the second if let could just use the already-bound value from line 1796, or the first binding could be restructured to avoid the repetition.

// shells are ended here, before the orphan reconciler below could revive them into a
// recovery window. Until 2026-09-18 they were restored as ordinary visible windows,
// so every window ever closed with the red button came back on the next launch.
let windowsToRestore = startupSnapshot.map { SessionPersistenceStore.windowsToRestore(from: $0) } ?? []
if let startupSnapshot {
endShellsOfHiddenWindows(SessionPersistenceStore.hiddenWindows(from: startupSnapshot))
}
let primaryWindowSnapshot = windowsToRestore.first
if let primaryWindowSnapshot {
isApplyingStartupSessionRestore = true
#if DEBUG
dlog(
"session.restore.start windows=\(startupSnapshot?.windows.count ?? 0) " +
"session.restore.start windows=\(windowsToRestore.count) " +
"primaryFrame={\(debugSessionRectDescription(primaryWindowSnapshot.frame))} " +
"primaryDisplay={\(debugSessionDisplayDescription(primaryWindowSnapshot.display))}"
)
Expand All @@ -1815,11 +1827,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
}
}

if let startupSnapshot {
let additionalWindows = Array(startupSnapshot
.windows
.dropFirst()
.prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1)))
if startupSnapshot != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The additionalWindows array no longer applies prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1)) after the refactor. The filtering in windowsToRestore(from:) already applies the limit, so this is correct, but the removal of the explicit prefix here means the limit is now enforced only inside SessionPersistenceStore. This is fine since windowsToRestore is the single source of truth, but worth noting that the old defensive clamp is gone.

let additionalWindows = Array(windowsToRestore.dropFirst())
#if DEBUG
for (index, windowSnapshot) in additionalWindows.enumerated() {
dlog(
Expand Down Expand Up @@ -1882,6 +1891,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
}
}

/// Ends the shells of windows the user had closed before the previous run ended.
/// `preserveMainWindowOnClose` keeps a closed window's PTYs alive so the Dock can reopen
/// it in the same run, and quit escrows them like any other session -- but a closed
/// window must not come back on relaunch, and without this the orphan reconciler would
/// revive those shells into a recovery window instead. Each session is retrieved from the
/// holder exactly like a reattach, then hung up (SIGHUP to the child, master fd closed)
/// and its WAL directory removed. Runs synchronously on the main actor at launch, before
/// any window restore, with the same per-session retrieve timeout a reattach pays.
private func endShellsOfHiddenWindows(_ hiddenWindows: [SessionWindowSnapshot]) {
guard !hiddenWindows.isEmpty, !SessionMachineryGate.isUnitTesting else { return }
var ended = 0
var sessionIds: [String] = []
for window in hiddenWindows {
for workspace in window.tabManager.workspaces {
for panel in workspace.panels where panel.type == .terminal {
sessionIds.append(panel.id.uuidString)
}
}
}
for sessionId in sessionIds {
startupEndedHiddenSessionIds.insert(sessionId)
if let meta = SessionWALStore.shared.readMeta(sessionId: sessionId),
meta.escrowed == true,
let socketPath = meta.escrowSocketPath,
let tokenHex = meta.escrowToken,
let masterFD = SessionEscrowClient.retrieve(
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.

endShellsOfHiddenWindows calls kill(childPID, SIGHUP) and close(masterFD) without checking the return values. While the function logs how many sessions were ended, a failed kill or close is silently counted as success. Consider logging individual failures so a hung process or bad fd doesn't go unnoticed.

tokenHex: tokenHex,
socketPath: socketPath
) {
if let childPID = meta.childPID, childPID > 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SessionWALStore.shared.discardOrphanedSession(sessionId:force:true) is called unconditionally after the retrieve block, even when retrieve returned nil. This means a session whose holder refused to hand over the fd still gets its WAL directory force-removed. The comment on startupEndedHiddenSessionIds says "a session the holder refused to hand over must not be revived either," which justifies the removal, but the data loss of a potentially recoverable session should be explicitly noted in a comment here.

kill(childPID, SIGHUP)
}
close(masterFD)
ended += 1
}
SessionWALStore.shared.discardOrphanedSession(sessionId: sessionId, force: true)
}
dilog(
"session.restore",
"hiddenWindows=\(hiddenWindows.count) sessions=\(sessionIds.count) ended=\(ended)"
)
}

/// Issue #307 orphan-reconciliation fix: the coarse-snapshot restore
/// that just completed above is keyed entirely by the panel UUIDs
/// already present in `session-<bundleId>.json` -- if that snapshot was
Expand All @@ -1908,7 +1961,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
/// closed immediately once the revived panel has taken its place in the
/// same pane, so no tab is ever left showing two panels or an empty one.
private func reconcileOrphanedEscrowedSessions() {
var known = Set<String>()
var known = startupEndedHiddenSessionIds
for context in mainWindowContexts.values {
for workspace in context.tabManager.tabs {
for panelId in workspace.panels.keys {
Expand Down
17 changes: 16 additions & 1 deletion Sources/SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,13 @@ struct SessionWindowSnapshot: Codable, Sendable {
var display: SessionDisplaySnapshot?
var tabManager: SessionTabManagerSnapshot
var sidebar: SessionSidebarSnapshot
/// `true` when the user had closed this window (`preserveMainWindowOnClose` keeps it
/// registered but ordered out). Restore never shows a hidden window again: it ends the
/// window's escrowed shells instead, so a closed window stays closed across a relaunch.
/// `nil` for snapshots written before this field existed -- treat as visible.
var isHidden: Bool?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The isHidden: Bool? field uses a nil-means-false convention for backward compatibility, which is clean. The computed property isHiddenWindow correctly coalesces to false. This is a good pattern, but consider whether var isHidden: Bool = false with a custom decoding init that defaults missing keys to false would be simpler and avoid optional unwrapping at every call site.


var isHiddenWindow: Bool { isHidden == true }
}

struct AppSessionSnapshot: Codable, Sendable {
Expand Down Expand Up @@ -670,7 +677,15 @@ enum SessionPersistenceStore {
from snapshot: AppSessionSnapshot,
limit: Int = SessionPersistencePolicy.maxWindowsPerSnapshot
) -> [SessionWindowSnapshot] {
Array(snapshot.windows.prefix(max(0, limit)))
// A window the user closed before the snapshot was written is never shown again;
// its shells are ended instead (`hiddenWindows(from:)`).
Array(snapshot.windows.filter { !$0.isHiddenWindow }.prefix(max(0, limit)))
}

/// Windows the user had closed (kept alive in-process by `preserveMainWindowOnClose`)
/// at the time the snapshot was written. Restore skips them and ends their shells.
static func hiddenWindows(from snapshot: AppSessionSnapshot) -> [SessionWindowSnapshot] {
snapshot.windows.filter(\.isHiddenWindow)
}

/// Archives the current snapshot file into `session-history/` before anything else can
Expand Down
42 changes: 42 additions & 0 deletions programaTests/AppDelegateShortcutRoutingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2763,6 +2763,48 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertTrue(appDelegate.tabManagerFor(windowId: windowId) === manager)
}

func testSessionSnapshotFlagsClosedWindowHiddenAndOrdersItLast() throws {
let appDelegate = try XCTUnwrap(AppDelegate.shared)
closeAllMainWindows()
let visibleWindowId = appDelegate.createMainWindow()
defer { closeWindow(withId: visibleWindowId) }
let closedWindowId = appDelegate.createMainWindow()
defer { closeWindow(withId: closedWindowId) }
let closedWindow = try XCTUnwrap(window(withId: closedWindowId))
let closedManager = try XCTUnwrap(appDelegate.tabManagerFor(windowId: closedWindowId))
_ = closedManager.addWorkspace()
let closedWorkspaceCount = closedManager.tabs.count

XCTAssertTrue(appDelegate.focusMainWindow(windowId: closedWindowId))
closedWindow.performClose(nil)
XCTAssertFalse(closedWindow.isVisible)
XCTAssertTrue(
appDelegate.tabManagerFor(windowId: closedWindowId) === closedManager,
"An ordinary close keeps the window registered for Dock reopen"
)

let snapshot = try XCTUnwrap(appDelegate.buildSessionSnapshot(includeScrollback: false))
XCTAssertEqual(snapshot.windows.count, 2)
let visible = try XCTUnwrap(snapshot.windows.first)
let hidden = try XCTUnwrap(snapshot.windows.last)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The test calls closedWindow.performClose(nil) and then asserts XCTAssertFalse(closedWindow.isVisible). This depends on preserveMainWindowOnClose keeping the window registered but hidden. If that behavior changes, this test will break. Consider adding a comment referencing the preserveMainWindowOnClose mechanism so future maintainers understand the dependency.

XCTAssertFalse(visible.isHiddenWindow, "The window the user can see must stay the primary restore entry")
XCTAssertTrue(hidden.isHiddenWindow, "A closed window is written flagged hidden, never as a visible one")
XCTAssertEqual(hidden.tabManager.workspaces.count, closedWorkspaceCount)
XCTAssertEqual(
SessionPersistenceStore.windowsToRestore(from: snapshot).count, 1,
"Restore must not bring a closed window back on the next launch"
)
XCTAssertEqual(SessionPersistenceStore.hiddenWindows(from: snapshot).count, 1)

XCTAssertTrue(appDelegate.reopenMostRecentlyHiddenMainWindow(onlyIfNoVisibleMainWindows: false))
XCTAssertTrue(closedWindow.isVisible)
let reopened = try XCTUnwrap(appDelegate.buildSessionSnapshot(includeScrollback: false))
XCTAssertTrue(
reopened.windows.allSatisfy { !$0.isHiddenWindow },
"Reopening from the Dock makes the window an ordinary restore entry again"
)
}

func testHiddenPrimaryWindowRetainsItsWindowAndWorkspaceUntilExplicitDisposal() throws {
let appDelegate = try XCTUnwrap(AppDelegate.shared)
AppDelegate.installWindowResponderSwizzlesForTesting()
Expand Down
28 changes: 28 additions & 0 deletions programaTests/SessionPersistenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,34 @@ final class SessionPersistenceTests: XCTestCase {
)
}

func testWindowsToRestoreSkipsHiddenWindowsAndOlderSnapshotsStayVisible() throws {
let base = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
var hidden = try XCTUnwrap(base.windows.first)
hidden.isHidden = true
let mixed = AppSessionSnapshot(
version: base.version,
createdAt: base.createdAt,
windows: base.windows + [hidden, hidden],
cleanShutdown: true
)

XCTAssertEqual(
SessionPersistenceStore.windowsToRestore(from: mixed).count, 1,
"A window the user closed before quitting must not be restored"
)
XCTAssertEqual(SessionPersistenceStore.hiddenWindows(from: mixed).count, 2)

// Snapshots written before `isHidden` existed carry no key at all.
let encoded = try JSONEncoder().encode(base)
XCTAssertFalse(String(decoding: encoded, as: UTF8.self).contains("isHidden"))
let decoded = try XCTUnwrap(SessionPersistenceStore.decodeSnapshot(from: encoded))
XCTAssertEqual(SessionPersistenceStore.windowsToRestore(from: decoded).count, 1)
XCTAssertTrue(SessionPersistenceStore.hiddenWindows(from: decoded).isEmpty)

let roundTripped = try XCTUnwrap(SessionPersistenceStore.decodeSnapshot(from: JSONEncoder().encode(mixed)))
XCTAssertEqual(SessionPersistenceStore.hiddenWindows(from: roundTripped).count, 2)
}

func testDecodeSnapshotDetectsVersionMismatchWithoutRequiringAppKit() throws {
let mismatched = makeSnapshot(version: SessionSnapshotSchema.currentVersion + 1)
let data = try JSONEncoder().encode(mismatched)
Expand Down
Loading