Skip to content

Persist terminal sessions across app launches with bundled zmx#334

Open
sbertix wants to merge 2 commits into
mainfrom
sbertix/terminal-persistence
Open

Persist terminal sessions across app launches with bundled zmx#334
sbertix wants to merge 2 commits into
mainfrom
sbertix/terminal-persistence

Conversation

@sbertix
Copy link
Copy Markdown
Collaborator

@sbertix sbertix commented May 19, 2026

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 on make build-app, and embedded at Contents/Resources/zmx/zmx. On next launch every surface re-attaches by its persisted UUID.

Two commits, layered:

e32f0206 Persist terminal sessions with bundled zmx

  • ZmxClient (attach / kill / ls), 5s subprocess timeout, drained pipes, sun_path budget probe with trailing-slash trim so a TMPDIR without slash doesn't desync.
  • Sidebar onboarding card; slot priority pre-empts older highlight / nested-worktrees prompts.
  • Close-surface focus invariant fix: GhosttySurfaceView.shouldClaimFocus reclaims firstResponder after split-tree rebuild without stealing from the command palette / inline rename. TerminalSplitTreePane wraps the per-tab projection read so SwiftUI invalidates on Cmd+D / Cmd+W.

372bf086 Quit confirmation, Terminate All menu, agent persistence

  • ConfirmQuitMode (auto / always / never). auto confirms only when active work would be lost (running scripts, mid-archive, busy agent). Legacy confirmBeforeQuit: true migrates to .always, false to .never so opt-out users don't silently get the dialog back.
  • Three-button alert via QuitConfirmationContext matrix; copy adapts when blocking scripts are in flight. New terminateSessionsOnQuit toggle pre-selects the destructive path.
  • File menu "Terminate All Terminal Sessions" gated on state.hasAnyTerminalSurface (a manager-emitted cached aggregate; the gate is a single Bool, never a sidebarItems iteration). Tears down tracked surfaces AND reaps daemon-hosted orphans, because zmx outlives the app.
  • Agent presence (claude / codex / etc.) is captured into SurfaceSnapshot.agents on background / quit and restored on next launch. Off-main kill(pid, 0) drops dead pids, post-projection re-fan-out resurrects badges as soon as surfaceIDs lands (handles the appLaunched race). Multiple agents per surface supported.
  • Per-tab close batches the zmx kill into one withTaskGroup + one count=N analytics event instead of N detached spawns. Launch reaper sweeps supa-* 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 iterates sidebarItems or any other IdentifiedArrayOf. Audited explicitly across the diff.

Test plan

  • make build-app clean at both commits.
  • make test green 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.
  • Manual: quit with claude running, relaunch, badge resurrects.
  • Manual: Quit and Terminate empties zmx ls output afterwards.
  • Manual: rapid Cmd+Tab does not write layouts.json more than once per ~1s.
  • Manual: archive a worktree, confirm its entry vanishes from ~/.supacode/layouts.json.
  • Manual: open command palette while a split-tree rebuild fires, confirm focus stays in the palette.

Closes #315
Closes #206

sbertix added 2 commits May 19, 2026 01:55
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.
@sbertix sbertix requested a review from khoi May 19, 2026 00:01
@tuist
Copy link
Copy Markdown

tuist Bot commented May 19, 2026

🛠️ Tuist Run Report 🛠️

Tests 🧪

Scheme Status Cache hit rate Tests Skipped Ran Commit
supacode 0 % 1362 0 1362 863957733

Builds 🔨

Scheme Status Duration Commit
supacode 2m 12s 863957733

@khoi
Copy link
Copy Markdown
Contributor

khoi commented May 19, 2026

big change I also tried to do it but couldn't get the UX right. Let me test this out

@khoi
Copy link
Copy Markdown
Contributor

khoi commented May 19, 2026

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

@khoi
Copy link
Copy Markdown
Contributor

khoi commented May 19, 2026

CleanShot 2026-05-19 at 16 44 02@2x

maybe a short paragraph explaining it under this option? Somehting along the line of

SC uses zmx to persists your sessions by default .... etc

Copy link
Copy Markdown
Contributor

@khoi khoi left a comment

Choose a reason for hiding this comment

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

minor UX comment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: Startup Script or auto reopen Claude sessions Default Terminal Layout

2 participants