Persist terminal sessions across app launches with bundled zmx#334
Open
sbertix wants to merge 2 commits into
Open
Persist terminal sessions across app launches with bundled zmx#334sbertix wants to merge 2 commits into
sbertix wants to merge 2 commits into
Conversation
Wrap every Ghostty surface in a `zmx attach <session-id>` so the underlying shell survives app quit and re-attaches on next launch with its scrollback and running processes intact. zmx is bundled as a git submodule pinned at v0.6.0 (commit 6084a4e), built from source via Zig 0.15.x as a pre-build step on `make build-app`, and embedded at `Contents/Resources/zmx/zmx`. - New `ZmxClient` (`Clients/Zmx/`) wraps `zmx attach` / `zmx kill` / `zmx ls --short`. `subprocessTimeout = 5s` caps every call so a stuck daemon can't pin the close path. stderr drains continuously through a `readabilityHandler`; the exit signal is a `terminationHandler`-backed `AsyncStream` so cancellation is well-behaved. - `ZmxSocketBudget.probe()` validates the resolved socket dir + session name fits under macOS' `sun_path` (104B) before wrapping, so a long custom `ZMX_DIR` can't silently overflow. Mirrors zmx's own `socket_dir` resolver (`ThirdParty/zmx/src/main.zig`) including the trailing-slash trim, so kill and the wrapped shell never end up on different dirs. - Sidebar onboarding card announces the feature; pre-empts the older highlight / nested-worktrees prompts via `SidebarBottomCardView.Slot` resolution. Close-surface focus invariant - `GhosttySurfaceView.shouldClaimFocus` + `pendingFocusClaim` reclaims firstResponder after a split-tree rebuild dropped the surface and AppKit didn't auto-promote on re-attach. Only steals from the no-owner / sibling-terminal case so a foreground command palette / inline rename text field is not yanked mid-keystroke. - `TerminalSplitTreePane` is a `private struct` (not a computed property) that reads the per-tab projection via `terminalsStore.terminalTabs[id: tabId]` so SwiftUI invalidates when Cmd+D / Cmd+W rebuilds the tree, without dragging in the full `WorktreeTerminalState` observation surface.
… presence persistence
Three layered features that share AppFeature, the terminal manager / state /
client wiring, and Settings UI; landing together to keep the build green at
this commit boundary.
Quit confirmation
- `ConfirmQuitMode` (.auto / .always / .never). `auto` confirms only when
active work would actually be lost (running scripts, mid-setup,
mid-archive, mid-delete, busy / awaiting-input agent). Legacy
`confirmBeforeQuit: true` migrates to `.always`, `false` to `.never`,
so opt-out users don't silently get the `.auto` dialog re-enabled.
- Three-button alert via `QuitConfirmationContext` matrix (Cancel /
Quit / Quit-and-Terminate). Copy adapts when blocking scripts are in
flight ("Quit and Stop Scripts", "Quit and Stop Everything").
- New `terminateSessionsOnQuit` toggle that pre-selects the destructive
path.
Terminate All Terminal Sessions menu
- File menu item beneath "Close Terminal Tab". Gated on
`AppFeature.State.hasAnyTerminalSurface` (a cached aggregate emitted
on flip by `WorktreeTerminalManager` via the
`terminalHasAnySurfaceChanged` event) so the menu read is a single
Bool, not an iteration over `sidebarItems`. Tears down every tracked
surface AND reaps any `supa-*` session zmx still hosts (zmx is a
long-lived per-user daemon that outlives the app).
Agent presence persistence
- `AgentPresenceFeature.records` is captured into per-surface
`SurfaceSnapshot.agents` on `applicationWillTerminate` and on
`scenePhaseChanged(.background)` (1s coalesce). On next launch the
records are re-hydrated from the layout, off-main `kill(pid, 0)`
drops dead pids, and a post-projection re-fan-out resurrects the row
badges as soon as `surfaceIDs` lands (fixing the race where restore
can win against `repositories(.task)` under `.merge`).
- Surface lifecycle (close, prune) implicitly drops agent records via
the layout file shrinking; no parallel store to keep in sync.
- Per-tab close batches the zmx kill into a single `withTaskGroup` and
emits one `count=N` analytics event instead of N detached spawns.
- Launch reaps `supa-*` orphans from crashes / force-quits whose owning
surface UUID isn't claimed by any persisted layout.
Contributor
|
big change I also tried to do it but couldn't get the UX right. Let me test this out |
Contributor
|
tested and it works well so far, I'm thinking about how to communicate that supacode is using zmx under the hood for the tech savvy users who'd like to know what's going on |
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Wraps every Ghostty surface in a
zmx attach <session-id>so the underlying shell, scrollback, and any running agents survive an app quit. zmx is bundled as a git submodule (v0.6.0), built from source via Zig onmake build-app, and embedded atContents/Resources/zmx/zmx. On next launch every surface re-attaches by its persisted UUID.Two commits, layered:
e32f0206Persist terminal sessions with bundled zmxZmxClient(attach / kill / ls), 5s subprocess timeout, drained pipes, sun_path budget probe with trailing-slash trim so aTMPDIRwithout slash doesn't desync.GhosttySurfaceView.shouldClaimFocusreclaims firstResponder after split-tree rebuild without stealing from the command palette / inline rename.TerminalSplitTreePanewraps the per-tab projection read so SwiftUI invalidates on Cmd+D / Cmd+W.372bf086Quit confirmation, Terminate All menu, agent persistenceConfirmQuitMode(auto / always / never).autoconfirms only when active work would be lost (running scripts, mid-archive, busy agent). LegacyconfirmBeforeQuit: truemigrates to.always,falseto.neverso opt-out users don't silently get the dialog back.QuitConfirmationContextmatrix; copy adapts when blocking scripts are in flight. NewterminateSessionsOnQuittoggle pre-selects the destructive path.state.hasAnyTerminalSurface(a manager-emitted cached aggregate; the gate is a single Bool, never asidebarItemsiteration). Tears down tracked surfaces AND reaps daemon-hosted orphans, because zmx outlives the app.SurfaceSnapshot.agentson background / quit and restored on next launch. Off-mainkill(pid, 0)drops dead pids, post-projection re-fan-out resurrects badges as soon assurfaceIDslands (handles theappLaunchedrace). Multiple agents per surface supported.withTaskGroup+ onecount=Nanalytics event instead of N detached spawns. Launch reaper sweepssupa-*orphans from crashes / force-quits.Performance
The aggressive observation discipline from #289 / #323 is preserved: every aggregate (
hasAnyTerminalSurface, agent presence, projection emit) is flip-detected and cached at the manager / reducer boundary so no view body iteratessidebarItemsor any otherIdentifiedArrayOf. Audited explicitly across the diff.Test plan
make build-appclean at both commits.make testgreen at HEAD, 1361 tests including new coverage for socket-dir trim, layout / agent round-trip, multi-agent restore with mixed liveness, race-fix regression, legacy quit-mode migration.make check(format + lint) clean.clauderunning, relaunch, badge resurrects.zmx lsoutput afterwards.layouts.jsonmore than once per ~1s.~/.supacode/layouts.json.Closes #315
Closes #206