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
15 changes: 14 additions & 1 deletion .claude/rules/control-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,14 @@ paths:
may have quit. Its `stop()` returns early without unlinking, leaving the owner's socket intact.
- One newline-delimited JSON request and response uses each connection, capped at 1 MiB. Unknown commands
return structured errors. Mutations may return `result.id`; trees use `result.tree`.
A decode failure reports the `DecodingError`'s `debugDescription`, not `localizedDescription`, so the
error NAMES the rejected `cmd`. That is the only signal a caller gets that its agterm predates its
agtermctl, and the reason a new command needs no version handshake.
- Human output shows IDs only for created session/workspace/window, retains them in JSON, uses
`result.affected` for session counts, and reserves `result.count` for diagnostics/search.
A command that reuses `count` for something else must carry its own `result.text`, which the shared
formatter prefers over every count spelling; `restore.capture` does, or its pane total would print as
"N diagnostic(s)".
- Targets accept active, case-insensitive UUID, or unique prefix. Batch targets resolve within the first
target's store, deduplicate, and fail atomically. Preserve the first target in the legacy top-level field
so old servers degrade to it rather than active.
Expand All @@ -132,7 +138,8 @@ renumbering. Do not reintroduce a count anywhere.
- `font.inc`, `font.dec`, `font.reset`
- `window.new`, `.list`, `.select`, `.close`, `.rename`, `.delete`, `.resize`, `.move`, `.zoom`,
`.fullscreen`, `.minimize`
- `keymap.reload`, `keymap.list`, `config.reload`, `theme.set`, `theme.list`, `restore.clear`
- `keymap.reload`, `keymap.list`, `config.reload`, `theme.set`, `theme.list`, `restore.capture`,
`restore.clear`

`debug.appearance` is a private `Command` case, absent from the list above, used only by `AppearanceFlipUITests`.
It accepts light/dark, sets `NSApp.appearance`, posts `.agtermSystemAppearanceChanged`, echoes the effective
Expand Down Expand Up @@ -593,6 +600,12 @@ side, and reads `lastAppliedIsDark` when bare. Refuse it outside XCUITest; provi

## Restore commands

- `restore.capture` fills those same captured main/split slots on demand, from every open window's live
panes, saves immediately, and captures no hidden split. It is app-global. It reports the slots it
actually WROTE in `result.count` plus its own `result.text`; counting the slots afterwards instead would
read a stale split capture as a fresh one. It REFUSES while `restoreRunningCommand` is off, which is the
one place this API does not follow `session.restore`'s note-and-succeed: see [[settings]] for why the
two differ and for the exits the command exists for.
- `restore.clear` clears captured main/split foreground commands across open windows and saves immediately.
It never clears durable `initialCommand`; it is app-global.
- `session.restore` pins per-session, per-pane next-launch behavior for discussion #264:
Expand Down
23 changes: 16 additions & 7 deletions .claude/rules/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,23 @@ paths:
libghostty diagnostics across all sources, clear all session zoom, post appearance change, and notify
non-zero diagnostics. A config-directory change reloads both co-located files. Launch also reports
cached diagnostics.
- **Restore running commands is opt-in, and both capture and replay are exit-scoped.**
`AppDelegate.captureForegroundCommands` runs at two points: `applicationWillTerminate` before
`saveAllOpen()`, and the LAST window's `willClose` before its surface teardown, which precedes
- **Restore running commands is opt-in. Replay is launch-scoped; capture runs at two exits and on demand.**
`AppDelegate.captureForegroundCommands` runs at three points: `applicationWillTerminate` before
`saveAllOpen()`, the LAST window's `willClose` before its surface teardown, which precedes
`applicationWillTerminate` and is therefore the only point where a close-the-last-window exit's
commands are still readable.
Guarded by `openIDs() == [windowID]`, skipped under `isTerminating`.
A NON-last close captures nothing: a launch restore can't tell that window's file from one open at
exit, so its argv could replay via the never-windowless reopen fallback.
commands are still readable, and `restore.capture` on demand, which exists for the exit that reaches
neither: a force quit, a crash, a hard reset, a power loss. A system shutdown/restart/logout is NOT in
that set — since #447 it reaches `applicationWillTerminate` like any quit — so do not re-motivate the
command with an OS update. The on-demand arm changes nothing else: it fills the same
slots, persists through the same `saveAllOpen`, and replay stays launch-only and one-shot.
All three arms are gated on the setting, and only the on-demand one SAYS so: it refuses while the
setting is off rather than capturing what nothing would replay. Deliberately unlike a `session.restore`
pin, which succeeds with an explanatory note in the same state, because a pin outlives the toggle and
a capture only goes stale.
The `willClose` arm alone is guarded by `openIDs() == [windowID]` and skipped under `isTerminating`.
A NON-last close captures nothing AND clears both persisted slots plus the pending pair: a launch
restore can't tell that window's file from one open at exit, so its argv could replay via the
never-windowless reopen fallback, and on demand a capture can now have written argv there mid-run.
Argv comes from `ghostty_surface_foreground_pid`, `sysctl(KERN_PROCARGS2)`, and host-free parsing.
Capture no hidden split.
Strip login `-` before shell recognition; a known shell with only flags is idle and omitted, while
Expand Down
25 changes: 19 additions & 6 deletions agterm/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -333,23 +333,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

/// Capture the given panes' foreground commands (main + split) into their `Session` fields for the
/// snapshot save. `ForegroundProcess` returns nil for a pane at its shell prompt, so plain shells stay
/// plain. Two callers on different lifecycle edges: `applicationWillTerminate` passes every open
/// session, `WindowAccessor`'s `willClose` passes one closing window's — on a close-the-last-window
/// exit the quit-time capture runs after that teardown, too late to see any surface. Both sites and
/// the launch-only replay gate are stated in `.claude/rules/settings.md`.
/// plain. Three callers on different edges: `applicationWillTerminate` passes every open session,
/// `WindowAccessor`'s `willClose` passes one closing window's — on a close-the-last-window exit the
/// quit-time capture runs after that teardown, too late to see any surface — and `restore.capture`
/// passes every open session on demand. All three sites and the launch-only replay gate are stated in
/// `.claude/rules/settings.md`.
///
/// Returns how many slots it actually WROTE a command into, which is what an on-demand caller reports.
/// Counting the slots afterwards instead would include a value this call never touched: the split slot
/// of a session whose split is hidden or gone still holds whatever an earlier capture put there.
@MainActor
static func captureForegroundCommands(sessions: [Session]) {
@discardableResult
static func captureForegroundCommands(sessions: [Session]) -> Int {
let shellBasename = ProcessInfo.processInfo.environment["SHELL"].map(CommandRestore.basename)
var captured = 0
for session in sessions {
if let view = session.surface as? GhosttySurfaceView {
session.foregroundCommand = ForegroundProcess.command(for: view, shellBasename: shellBasename)
if session.foregroundCommand != nil { captured += 1 }
}
// only a SHOWN split is recreated on restore, so gate on isSplit — a hidden split's captured
// command would sit stale until the next ⌘D fires it.
// command would sit stale until the next ⌘D fires it. Clearing it in the else keeps that stale
// value out of the snapshot now that a capture can run more than once per launch.
if session.isSplit, let split = session.splitSurface as? GhosttySurfaceView {
session.splitForegroundCommand = ForegroundProcess.command(for: split, shellBasename: shellBasename)
if session.splitForegroundCommand != nil { captured += 1 }
} else {
session.splitForegroundCommand = nil
}
}
return captured
}

func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
Expand Down
39 changes: 37 additions & 2 deletions agterm/Control/ControlServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,12 @@ final class ControlServer {
do {
request = try JSONDecoder().decode(ControlRequest.self, from: line)
} catch {
writeResponse(conn, ControlResponse(ok: false, error: "invalid request: \(error.localizedDescription)"))
// `debugDescription` over `localizedDescription`: a DecodingError's localized string is the generic
// "data couldn't be read", while the debug one names the offending value — for an unknown `cmd` that
// is the difference between a mystery and "Cannot initialize Command from invalid String value
// restore.capture", which tells a caller its agterm is older than its agtermctl.
let detail = (error as? DecodingError).map(String.init(describing:)) ?? error.localizedDescription
writeResponse(conn, ControlResponse(ok: false, error: "invalid request: \(detail)"))
return
}

Expand Down Expand Up @@ -433,7 +438,7 @@ final class ControlServer {
.windowNew, .windowList, .windowSelect,
.windowClose, .windowRename, .windowDelete, .windowResize, .windowMove, .windowZoom,
.windowFullscreen, .windowMinimize,
.restoreClear, .dashboard:
.restoreClear, .restoreCapture, .dashboard:
return ControlResponse(ok: false, error: "control dispatcher did not handle \(request.cmd.rawValue)")
case .debugAppearance:
return setDebugAppearance(args: request.args)
Expand Down Expand Up @@ -492,6 +497,36 @@ final class ControlServer {
return ControlResponse(ok: true)
}

/// Capture every open pane's live foreground command NOW, filling the same slots the quit-time capture
/// fills, then persist them. The point is the exit that never runs `applicationWillTerminate`: a crash, a
/// SIGKILL, a hard reset, or a restart that outruns the app's termination window. Run this from a
/// scheduled job or a keybind and such an exit restores like a ⌘Q.
///
/// App-global like `clearRestoreCommands`, its inverse over the same slots: no `--window` selector, every
/// open window. Consumption stays one-shot and launch-only, so nothing here changes replay.
///
/// Gated on the same setting as the two exit-time captures, and refuses rather than answering ok: with the
/// setting off a capture is write-only — the launch replay reads the setting too, so nothing would run —
/// and a scheduled caller needs a non-zero exit to notice. It also keeps "the setting is off" meaning
/// "argv never reaches the disk", which a silent capture would break for a user who opted out.
func captureRestoreCommands() -> ControlResponse {
guard settingsModel.settings.restoreRunningCommand == true else {
return ControlResponse(ok: false,
error: "\"Restore running commands on restart\" is off, nothing was captured")
}
let sessions = library.allOpenSessions()
let captured = AppDelegate.captureForegroundCommands(sessions: sessions)
// this command's whole claim is that the argv reached disk, so the ack waits on the write and not on
// the assignment: `saveAllOpen` swallows the result, `saveAllOpenChecked` reports it.
guard library.saveAllOpenChecked() else {
return ControlResponse(ok: false, error: "captured \(captured) pane\(captured == 1 ? "" : "s") "
+ "but at least one window's save failed; the argv stays in memory and the next save writes it")
}
var result = ControlResult(count: captured)
result.text = "captured \(captured) pane\(captured == 1 ? "" : "s")"
return ControlResponse(ok: true, result: result)
}

/// Open or close the target window's dashboard overlay — the app side of the host-free `dashboard`
/// command (the dispatcher validated the args and built `fontMode`, but does not cap the ids). Resolves
/// `window ?? frontmost` to an OPEN window's store. `mru` takes up to `DashboardLayout.maxCells` of that
Expand Down
14 changes: 14 additions & 0 deletions agterm/Views/WindowAccessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,20 @@ struct WindowAccessor: NSViewRepresentable {
if !library.isTerminating, library.openIDs() == [windowID],
GhosttyApp.shared.restoreRunningCommand {
AppDelegate.captureForegroundCommands(sessions: store.workspaces.flatMap(\.sessions))
} else if !library.isTerminating {
// a NON-last close must leave no argv in this window's file: a launch restore cannot
// tell it from a file open at exit, so the never-windowless reopen fallback could
// replay it. Before `restore.capture` the live field was always nil mid-run and the
// invariant held by construction; now it has to be enforced here.
// Termination belongs to NEITHER arm: `applicationWillTerminate` captured over live
// surfaces and persisted already, then `closeWindow` no-ops under the flag so this
// runs with the store still loaded. Clearing here writes nulls over that capture and
// every ⌘Q comes back a plain shell, `restore.capture` callers included.
for session in store.workspaces.flatMap(\.sessions) {
session.foregroundCommand = nil
session.splitForegroundCommand = nil
session.clearPendingForegroundCommands()
}
}
store.save()
}
Expand Down
7 changes: 5 additions & 2 deletions agtermCore/Sources/agtermCore/AppStore+Panes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,13 @@ extension AppStore {
session.splitCwd = nil
session.splitTitle = nil
session.initialSplitCwd = nil
// the right pane is gone: drop both the persisted pin and any payload still armed for this launch,
// so a fresh split is a plain shell.
// the right pane is gone: drop the persisted pin, the captured command, and any payload still armed
// for this launch, so a fresh split is a plain shell. The capture slot matters since `restore.capture`
// can fill it mid-run: left behind, a re-split would arm the dead pane's command on the next launch.
session.splitRestoreCommand = nil
session.pendingSplitRestoreCommand = nil
session.splitForegroundCommand = nil
session.pendingSplitForegroundCommand = nil
session.splitRatio = nil // tearing down the split clears its geometry too, so a fresh split opens even
// the right pane is gone, so its overlay has nothing left to cover and nobody left to read its status.
session.teardownPaneOverlay(.right)
Expand Down
6 changes: 3 additions & 3 deletions agtermCore/Sources/agtermCore/CommandRestore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ public enum CommandRestore {
/// - `wasRestored`: the session came from a restore (a FRESH command session always runs its command, a
/// RESTORED one only when the opt-in is on).
/// - `restoreEnabled`: the `restoreRunningCommand` opt-in.
/// - `hadForeground`: a foreground command was CAPTURED at the last quit. It PREEMPTS `initialCommand`
/// even when suppressed (denylisted/off → `foregroundInput` nil), yielding a plain shell rather than
/// the stale creation command — so gate on capture, not on the input surviving.
/// - `hadForeground`: a foreground command was CAPTURED, at the last quit or by `restore.capture`. It
/// PREEMPTS `initialCommand` even when suppressed (denylisted/off → `foregroundInput` nil), yielding a
/// plain shell rather than the stale creation command — so gate on capture, not on the input surviving.
/// - `foregroundInput`: the rendered foreground command line to type, or nil (none / suppressed).
/// - `initialCommand`: the session's persisted `--command`.
/// - `restoreOverride`: the pane's pinned restore command (`session.restore`), tri-state — nil = no
Expand Down
7 changes: 6 additions & 1 deletion agtermCore/Sources/agtermCore/ControlDispatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ public protocol ControlActions {
/// Cancel a native picker. The host owns window resolution, registry lookup, and dismissal.
func cancelPick(_ target: String, window: String?) -> ControlResponse
func clearRestoreCommands() -> ControlResponse
/// Capture every open pane's foreground command now, the same read `applicationWillTerminate` does. The
/// host owns the `sysctl` read, the save, and the count it reports back.
func captureRestoreCommands() -> ControlResponse
}

public extension ControlActions {
Expand Down Expand Up @@ -228,7 +231,7 @@ public struct ControlDispatcher {
return dispatchWorkspaceCommand(request)
case .quick, .fontInc, .fontDec, .fontReset, .keymapReload, .keymapList,
.configReload, .notify, .themeSet, .themeList, .sidebar, .sidebarMode, .sidebarExpand,
.sidebarCollapse, .restoreClear:
.sidebarCollapse, .restoreClear, .restoreCapture:
return dispatchAppCommand(request)
case .quickType, .quickText:
return await dispatchQuickCommand(request)
Expand Down Expand Up @@ -737,6 +740,8 @@ public struct ControlDispatcher {
return actions.collapseSidebar(window: request.args?.window)
case .restoreClear:
return actions.clearRestoreCommands()
case .restoreCapture:
return actions.captureRestoreCommands()
default:
preconditionFailure("unexpected app command: \(request.cmd.rawValue)")
}
Expand Down
1 change: 1 addition & 0 deletions agtermCore/Sources/agtermCore/ControlProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ public enum Command: String, Codable, Sendable {
case pickResult = "pick.result"
case pickCancel = "pick.cancel"
case restoreClear = "restore.clear"
case restoreCapture = "restore.capture"
/// UI-TEST-ONLY: forces the app-level appearance (`light`|`dark` via `args.name`) so an XCUITest can
/// simulate a macOS light/dark flip; with NO name it READS the side the last config feed applied, so a
/// test can assert the flip drove the reload. Refused outside an XCUITest launch, and EXEMPT from the
Expand Down
5 changes: 3 additions & 2 deletions agtermCore/Sources/agtermCore/Snapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,9 @@ public struct SessionSnapshot: Codable, Equatable, Sendable {
public var splitRatio: Double?
/// Whether the session is in the flagged working-set; nil = not flagged.
public var flagged: Bool?
/// The main pane's foreground command (full argv) at the last clean quit, re-run on restore when
/// `AppSettings.restoreRunningCommand` is on. nil at a shell prompt, or with the feature off.
/// The main pane's foreground command (full argv) as of the last clean quit or the last
/// `restore.capture`, re-run on restore when `AppSettings.restoreRunningCommand` is on. nil at a shell
/// prompt, or with the feature off, which gates every capture site.
public var foregroundCommand: [String]?
/// The split (right) pane's foreground command (full argv), the split analogue of `foregroundCommand`.
public var splitForegroundCommand: [String]?
Expand Down
Loading
Loading