Skip to content

Live session restore backed by embedded zmx - #515

Merged
umputun merged 48 commits into
masterfrom
persistent-sessions
Aug 31, 2026
Merged

Live session restore backed by embedded zmx#515
umputun merged 48 commits into
masterfrom
persistent-sessions

Conversation

@umputun

@umputun umputun commented Aug 31, 2026

Copy link
Copy Markdown
Owner

adds live session restore: panes run inside zmx, a small session attach/detach multiplexer embedded in the app bundle, so processes survive an app quit and reattach on the next launch. No separate install. Experimental and off by default.

Three restore modes

  • Fresh shells, what you get today with restore off
  • Re-run commands, the existing behaviour
  • Live sessions, new and opt-in only

Mode changes apply after restarting agterm.

Live is never selected automatically. When a settings file has no restoreMode key, AppSettings.effectiveRestoreMode resolves it through the legacy restoreRunningCommand boolean: true becomes Re-run commands, false or a missing key becomes Fresh shells. Neither branch can produce Live, so an upgrading user keeps the behaviour they had and a fresh install starts where master's default was.

How a live pane survives

Each pane carries a stable daemon name. Attach carries a create-only payload, so a surviving daemon ignores it. If the daemon is gone after an orderly machine restart, zmx recreates the persistent shell and runs an eligible capture, or the durable creation command when no capture exists.

Bundled zmx

scripts/setup.sh builds zmx from neurosnap/zmx at a pinned rev. An Xcode phase copies it to Contents/MacOS/zmx and signs it ad hoc with the hardened runtime.

Live needs a zsh login shell. The wrapper points ZDOTDIR at Ghostty's zsh integration and chains the user's own through GHOSTTY_ZSH_ZDOTDIR. The current wrapper has no equivalent chain for the other shells, so users of bash, fish, nushell or elvish fall back to Fresh shells, with the reason shown in Settings.

Control API

restore.mode reads and writes the policy. zmx.list, zmx.prune and zmx.kill inspect and manage the daemons behind live panes. Arguments and read-back fields are in site/commands.html.

Non-live behaviour changes

Three things differ from master even when Live is never selected:

  • a split hidden at quit is now restored as a hidden split, so its saved pane state can apply when it is shown. Master-era snapshots contain no hidden split to recover.
  • a captured command waiting for a delayed pane survives another clean quit instead of being discarded. A factory that already consumed the command clears it, so this does not replay a consumed command twice.
  • exit capture is time-bounded. Once its deadline expires, panes not yet inspected get no new capture and normally restore fresh shells; master attempted every pane before quitting.

Compatibility

No public agtermCore symbol is removed or narrowed. No control command or CLI flag is dropped. Keymap grammar and built-in actions are unchanged, and the persisted snapshot additions are all optional. Command gains four cases, which can break an external exhaustive switch; the known agterm-linux consumer does not switch over Command.

Related to discussion #264: live mode avoids re-running the non-idempotent commands discussed there on an ordinary app restart, without closing the discussion.

umputun added 30 commits August 30, 2026 14:23
Run a primary pane's shell under `zmx attach` when AGTERM_ZMX_PATH names a zmx
binary, so the shell and whatever runs in it survive an app restart and the
session reattaches to the live daemon instead of respawning.

The wrapper replaces the login shell through `config.command`, which stops
ghostty detecting a shell and injecting its integration, so the integration
environment is supplied per surface instead: ZDOTDIR at the bundled zsh
integration, any existing value preserved as GHOSTTY_ZSH_ZDOTDIR, and SHELL
resolved from the password database rather than the GUI process environment.
Falls back to an ordinary unwrapped shell when the login shell is not zsh, the
bundled integration is absent, or the socket path would exceed sun_path.

initial_input is no longer suppressed alongside a command for a wrapped
surface. The exclusion is agterm policy for a command that replaces the shell;
here the command is the attach client, which forwards the input to the
daemon-side shell.

A wrapped pane skips the quit-time foreground capture, since the daemon rather
than a replayed command line owns what comes back.
Supersedes the shelved ideas plan, which is wrong in five ways established from
source during design: command and initial_input are not mutually exclusive in
libghostty, the zig pin moved to 0.16, persistence is a global mode rather than
a per-session flag, role-derived daemon names cross-wire a promoted split, and
shell integration behind a set command is the mechanism the whole route turns
on and went unexamined.
Supersedes the shelved ideas plan, which is wrong in five ways established from
source and runtime during design: command and initial_input are not mutually
exclusive in libghostty, the zig pin moved to 0.16, persistence is a global
mode rather than a per-session flag, role-derived daemon names cross-wire a
promoted split, and shell integration behind a set command is the mechanism the
whole route turns on and went unexamined.

Scope is primary and split panes, zsh only in v1, with scratch, overlay and
quick terminals staying ephemeral. Hidden splits keep today's contract: alive
while the app runs, gone after a relaunch, since nothing persists them.
Protect claimed daemons when a live request falls back, and keep fresh --command behavior. Move zmx process work off the main actor and apply the retained review cleanup across tests and docs.
Make the tree-triggered refresh populate its cache before projection and keep reap and semantic kills ordered. Reuse the core restore planner and strengthen the requested-live fallback test around the full launch decision.
Plans a `zmx` command group over the control socket — `list`, `prune`, `kill` —
plus a `restore mode` read/set, so a user can see which daemons exist, who owns
them and which are leaked, without learning zmx's own CLI.

The group is deliberately named `zmx`: the backend becomes part of the CLI
compatibility surface, in exchange for a user who spots an `agterm-3f2a…`
process in `ps` being able to search one word and find our docs. Today five
places say "manual zmx kill" without ever saying how.

Every command goes over the control socket and fails with no instance when
agterm is stopped. A running owner is the only thing that can join live stores,
pending-close records, checked closed-window snapshots, the directory-versus-
index comparison and the observed daemon list into one answer.

`prune`'s gate is conjunctive but checked rather than atomic, and the plan says
so: pinned zmx has no kill-if-detached, `--force` is consulted only when the
connection fails, and a successful connection kills regardless of clients. The
window is accepted because closing it needs an upstream change against a pinned
rev. `unreadable` rows are refused in v1 because forcing one can unlink a live
daemon's socket and still exit zero.

Designed with codex over three review passes.
Prune re-lists immediately before it mutates and drops any candidate no longer
observed at zero clients, with model resolution held on the main actor. That
narrows the check-then-act window without an upstream zmx capability; what
remains is an external attach in the gap, which the docs must state plainly
rather than imply away.

Success is counted only from the exact 'killed session <name>' line, which zmx
prints after draining to EOF and so means the daemon actually hung up. The
client method is named killObservedOrphan rather than killIfDetached: promising
a gate zmx does not provide is what produced this blocker.

Review findings from codex.
ZmxInventory.join is the primitive behind the planned `zmx list`: it unions
what zmx reports with what agterm's panes expect, so a leaked daemon and a pane
whose daemon vanished are both explained rather than one of them dropped.

State is deliberately six-valued. A closed-but-remembered window's panes are
claimed with zero clients, which is the normal steady state, so the client count
alone can never imply an orphan — and an unmatched name is only an orphan when
the claim walk saw everything, otherwise it is unknown. Two panes claiming one
daemon collapse to a single conflicted row with no owner and clear the result's
completeness, since neither answer can be acted on.

ZmxSupport.isDaemonName validates the exact shape daemonName(for:) emits rather
than its prefix. A prefix test made a user's own `agterm-notes` session read as
an unclaimed app daemon, which prune would then kill. It compares ASCII bytes
because Character.isHexDigit is Unicode-aware and accepts fullwidth digits.

ZmxPrunePolicy gates on a complete conflict-free inventory, an identity no pane
claims, and a daemon observed running with no clients. Observed, not guaranteed:
pinned zmx has no kill-if-detached, so the caller revalidates before mutating.

Task 1 of docs/plans/20260828-zmx-control-cli.md. Reviewed by codex.
paneClaims() answers which panes expect a daemon, for the planned `zmx list`.
It is deliberately its own walk rather than PaneIdentityInventory.upgrade, which
mints missing identities and whose every caller saves the result: a read command
must not rewrite window files. A missing identity makes the walk incomplete
instead of being repaired, and a test fingerprints windows.json and every
windows/*.json to prove nothing moved.

It enumerates windows/*.json and compares against the index rather than trusting
the index alone. bootstrap() only scans the directory when loadIndex() returns
nil, so a valid-but-stale windows.json leaves a surviving window file unread and
its panes would read as orphan daemons. A directory that cannot be enumerated is
incomplete rather than empty, since that silence is indistinguishable from
having no stray files and prune must not act on it.

Each pane is judged on its own: a session missing its primary identity can still
own a good split, and dropping that would strand the split's daemon. Sessions
carry a human label -- displayName live, and the same fall to the cwd's last
component from a snapshot -- so the table reads without ids.

Task 2 of docs/plans/20260828-zmx-control-cli.md. Reviewed by codex.
A soft close hides the session from workspaces for the grace window while its
surfaces, and so its daemons, stay alive. The claim walk now reads those records
through AppStore.pendingCloseMembers, or a live claim would report as an orphan
and prune would take it.

Reading them exposed a real leak on two paths. closeWindow dropped the store
outright and removeWindow inventoried only workspaces, so a window closed or
deleted during the grace took the pending record with it and nothing ever
finalized the held identities -- the daemon outlived the window with no owner
left to kill it. Both now finalize pending closes against the live store first,
which WindowAccessor already did on its own path, so it is idempotent.

The accessor is internal: its only caller is WindowLibrary inside agtermCore,
and exporting live Session references would widen the surface the agterm-linux
fork consumes for nothing.

Task 3 of docs/plans/20260828-zmx-control-cli.md. Reviewed by codex.
restore.mode reads the policy and writes it for the next launch. Settings was
the only way to see or change it, and its active value was reported nowhere, so
a CLI user flipping the mode had no way to tell that this process kept the one
it started with.

The status carries five fields because two 'requested' values exist once the
mode can change mid-run: configured is what the next launch will ask for,
requestedAtLaunch what this one did, and active what it got. It derives
restartRequired itself so no producer can disagree, and reports
unavailableReason only when live was actually requested and refused --
RestoreLaunchDecision holds a probed reason even under none and rerun, and
showing it there tells a rerun user their shell is unsupported for a mode they
never chose.

Modes travel as raw strings although the producer holds typed RestoreMode
values. RestoreMode's decoder is deliberately lossy so a settings file from a
newer build is not discarded, and reusing it on the wire would make a stale CLI
print a future mode as none -- the mode whose next launch reaps every daemon.
The dispatcher parses strictly for the same reason: an unknown mode is refused
by name rather than falling back.

SettingsModel.setRestoreMode now rolls memory back on a failed write and reports
it, following AppStore.setRestoreCommand. Acknowledging a policy the disk
rejected would promise a next launch that is not coming.

Tasks 4 and 5 of docs/plans/20260828-zmx-control-cli.md, committed together: a
new ControlActions requirement and its only conformer cannot build apart.
ControlProtocol.swift was one line under its length limit, so the nested result
payloads moved to ControlPayloads.swift.
zmx.list joins what zmx reports against what agterm's panes expect, so a leaked
daemon and a pane whose daemon vanished are both explained. It carries the
restore status as a header, so a reader can tell whether the rows describe a
live-mode instance without a second call.

A failed listing is an error rather than an empty inventory: an empty namespace
is a real answer, and not having looked must not be indistinguishable from
having nothing to see.

ZmxClient gains listSessions and reaches ControlServer for the first time. It
had lived only inside the finalizer and reap closures, where nothing else could
call it, so the control layer had no way to ask zmx anything.

Every new ControlActions requirement ships a default that refuses by name.
agtermCore is a library the agterm-linux fork consumes and its AppController
conforms to this protocol, so a Mac-only requirement without a default breaks
that build -- which the previous commit did, for restore.mode.

Row states and observations travel as raw strings, like the restore modes: a
strict enum on the wire would make a future state fail the whole response
rather than one field.

Task 6 of docs/plans/20260828-zmx-control-cli.md, plus codex's findings on
34e1b3bd -- the downstream defaults, the full request/response round trips, and
clearRestoreCommands' doc, which my insertion had detached from its function.
ControlDispatcher.swift hit its length limit, so the defaults extension moved to
ControlActionsDefaults.swift.
zmx.prune acts on the rows zmx list already explained, so a user can see why a
daemon is eligible before it goes.

The gate is checked and revalidated rather than atomic, and the code says so.
Pinned zmx has no kill-if-detached: --force is consulted only when the
connection fails, and a successful connection kills regardless of clients. So
prune re-lists immediately before mutating and drops any candidate that gained
a client in between, with model resolution held on the main actor so agterm's
own claims cannot move underneath it. What remains is a client attaching from
outside agterm inside that gap.

It never passes --force, for a reason that is not the client count: on a failed
connection --force unlinks the socket, prints 'cleaned up stale session' and
exits zero, which can leave a live but unresponsive daemon running, orphaned and
unreachable by name. killObservedOrphan invokes one name at a time and counts
only the confirmed 'killed session NAME' line, which zmx prints after draining
to EOF and so after the daemon actually hung up. A stale-socket cleanup is
reported as exactly that.

The name is killObservedOrphan rather than killIfDetached: it kills what the
listing observed and guarantees nothing about the moment of the kill. Promising
a gate zmx does not provide is what produced this design's blocker.

Task 7 of docs/plans/20260828-zmx-control-cli.md.
Three findings from codex on 8093c9a2.

The inventory round trip encoded the bare payload, so result.zmx -- the field a
caller actually reads -- was untested. It now goes through a whole
ControlResponse and asserts an unclaimed row OMITS its owner fields rather than
nulling them.

listSessions had no client-level test at all: nothing pinned it to exactly
["list"], to the instance's ZMX_DIR with no inherited ZMX_SESSION, or to the
difference between an empty namespace and a failed invocation. killObservedOrphan
gets the same treatment, including that it never passes --force.

ControlServer.zmxClient's comment claimed it is nil outside live mode. It is
present in every real launch, and it must be: list and prune are most useful
after a launch in none or rerun, which is exactly when detached daemons are left
over. The comment would have become a gate someone later enforced.
zmx.kill is the escape hatch for a daemon the user wants gone. It requires an
explicit target, pane and --force, and the dispatcher refuses without any of
them before the host is called. Not because other close commands are
recoverable -- session.close is already immediate and split close has no Reopen
path -- but because this destroys a backend process that can reach a claim no
window shows and takes down every client attached to it, so there is no useful
default for who is affected.

Resolution runs against the inventory, not ControlTargetResolver, which searches
open stores only: the command deliberately reaches closed and unindexed claims.

After a successful kill it marks the surface's exit handled through the existing
didHandleProcessExit, never a parallel flag, and only AFTER the kill so a failed
one leaves the natural exit path working. It then drives handlePaneExit, which
owns the model transition plus the promoted survivor's font callback, its
dashboard membership and the refocus -- a store-only transition would skip all
three.

absent and unreadable rows are refused: nothing to kill in the first case, and
in the second a forced kill can unlink a live daemon's socket and still exit
zero, leaving it running and unreachable by name. pendingClose is refused too;
an undo window is still the user's session.

Also fixes the kill-outcome parsing codex found in b0bd4026. Matching a
substring counted zmx's zero-exit 'is unresponsive' report and an empty
broken-pipe return as stale-socket cleanups, and would have read a line merely
containing the confirmation as a kill. It now matches exact trimmed lines and
treats everything else as a failure, so a daemon whose fate is unknown is never
reported gone.

Task 8 of docs/plans/20260828-zmx-control-cli.md.
Two defects codex found in 79190764, both reachable.

applyKilledPaneExit treated any realized surface as the client of the daemon it
had just destroyed. On a requested-live launch that fell back, the reap
PRESERVES claimed daemons while each pane comes up as a plain shell, so killing
that leftover daemon closed a live pane which had never attached to it. It now
requires backedByZmx, and the hosted test fails without that guard.

killZmxDaemon ignored its window argument, so a prefix ambiguous across windows
could not be disambiguated and an exact session id in another window was killed
regardless of an explicit selector. The window now scopes the claims before the
session resolves.

Adds the hosted coverage task 8 required and did not have: a successful attached
kill promoting a split survivor with the queued callback proven inert
afterwards, a primary with no survivor closing the session while the killed
identity is not finalized twice, a failed kill leaving the pane's own exit path
working, and the cross-window cases above.

Also task 9: the agtermctl surface. zmx list/prune/kill and restore mode, with
--pane and --force validated client-side so a bad invocation never reaches the
socket, and human output that gives owner window state its own column -- a
closed window's panes rest at zero clients, and a reader given only the count
would read that as a leak.

Tasks 8 and 9 of docs/plans/20260828-zmx-control-cli.md.
Three findings from codex on a13c3902.

zmx kill did not actually require a target. It embedded TargetOptions, whose
target defaults to 'active', so 'zmx kill --pane left --force' parsed and
reached the socket -- defeating the safety contract its own help text describes.
It now takes a required --target with no default.

'active' is refused on both selectors, client- and server-side, rather than
resolved. The contract is that nothing about this destruction falls back to
whatever is in front of the user, and a window selector silently meaning
'frontmost' would reintroduce exactly that.

Human 'zmx list' printed names only, so the workflow could not be completed from
it: kill resolves a session by id or prefix, never by name, and a closed or
unindexed row may not appear in tree at all. Rows now carry a copyable session
prefix and, for the ambiguous cases, the window's. Observation is its own column
beside the client count, which only exists for a running daemon.

Adds the verification task 8 still owed: the zmx.kill request and response
round-tripped through JSON, the promotion test asserting the rewired font
callback and cleared split role rather than only the model shape, and a kill
resolved against an unindexed window that ControlTargetResolver cannot see.

Task 10 rides along: control-api.md records the whole contract including why
restore.mode reports through its own read rather than a tree node, and all five
bare 'manual zmx kill' mentions across README, site/docs.html and the three
skill files now name the command that does it.
Acceptance criteria verified against the four gates: make build, 2740 agtermCore
tests, make test-app, and make lint, all green. No surface states a command
total, so the catalog rule still holds with four commands added.

Fills the last two documentation gaps site/docs.html owed: that prune's check is
revalidated rather than atomic, so a client attaching from outside agterm in the
remaining moment can still be terminated, and what each kill outcome does to the
session -- neither of which a user can infer from the command names.

Plan moved to docs/plans/completed/.
umputun added 18 commits August 30, 2026 14:23
Four findings from codex on a2cb6d60.

site/commands.html was structurally broken: the restore section closed before
the restore-mode card, leaving it and the three zmx cards as loose blocks
between restore and version, with no zmx heading, id, or nav entry. Restore mode
now sits inside restore, zmx is a real section, and the nav links to it.

Human zmx list omitted the workspace although the settled mapping is
window/workspace/session/pane and the payload already carried it. One session
name can appear in two workspaces, which the daemon name alone cannot
disambiguate. The short-id doc no longer claims eight hex digits are unique: an
ambiguous prefix is refused by the resolver, and --json carries the full ids.

The promotion test claimed follow-ups it did not prove. It now opens a real
dashboard on the split member and asserts the cell moved to the primary slot,
which reconcile cannot do because a promotion and a split's own exit leave
identical state. The focus assertion is gone rather than restated: splitFocused
is model state and proves nothing about focusAfterReparent having run.

README says agtermctl zmx prune rather than a bare zmx prune, and 'detached
orphans' rather than 'the ones nothing claims' -- an externally attached orphan
is deliberately retained. Flowing-prose em dashes in the skill reference are
replaced; the command-to-description separators stay, matching every neighbouring
entry in those files.
The high finding from codex's whole-series read, and the same defect it caught
in prune that I failed to carry across to kill.

killZmxDaemon used the semantic-deletion ZmxClient.kill, which discards stdout
and reads every zero exit as success. Pinned zmx exits zero after merely
unlinking a socket it could not reach, and after a broken pipe with nothing
printed. So the command could report a kill, claim the surface exit, and
close or promote a live pane while the daemon kept running, unreachable by name.

killConfirmed force-kills one daemon and returns the parsed outcome. Only the
exact 'killed session NAME' line runs the pane transition; a stale-socket
cleanup, an empty return and an unresponsive report are all refusals that leave
the natural exit path armed. The Boolean kill stays for semantic deletion, where
the model is already gone and there is nothing left to be wrong about.

The hosted success tests had been passing for the wrong reason: their kill
runner returned empty output, which the outcome parser classifies as a failure.
Fixing the runners made one of them fail, which is the defect showing itself.

Zmx.Kill takes its own --window rather than ClientOptions, whose help promised
'active' and a frontmost default. Both are wrong here: omitting it searches
every window's claims, closed and unindexed included.

result.affected now documents zmx.prune's killed-daemon count, and the
response-shape summaries in reference.md and SKILL.md list restore and zmx.

The completed plan no longer claims the refocus is covered. focusAfterReparent
needs a first responder a hosted test has no window for, so it rides
handlePaneExit unproven rather than being asserted through model state that
proves nothing about it.
zmx.prune reports its killed-daemon count in result.affected, but three
mirrored summaries still defined that field as sessions changed by a batch
close or move: ControlProtocol's own doc comment, the skill reference's payload
list, and site/commands.html's response-shape paragraph, which also omitted
affected, restore and zmx entirely.

Found by codex, which also caught that my previous handoff claimed the skill
reference had been updated when only its payload list had.
The reap selected kill candidates by the `agterm-` prefix while every
non-destructive path used `ZmxSupport.isDaemonName`. `ZMX_DIR` is exported into
every wrapped pane, so a user's own `zmx new agterm-build` left detached was
force-killed at the next launch, unconditionally in none and rerun mode.

The launch pane inventory also trusted `windows.json` alone. `bootstrap()`
rebuilds the index from the directory only when `loadIndex()` returns nil, so a
valid-but-stale index left a surviving window's file unread and the reap
destroyed exactly its panes -- at launch every daemon has zero clients, the
previous process's attach clients having died with it. It now enumerates
`windows/*.json` through the same helper the read-only claim walk uses, claims
readable strays, and marks the inventory incomplete only when enumeration, read,
upgrade or save fails.

Test fixtures using names the app can never emit are replaced with generated
daemon names, so they pin the shape that ships.
…l --window

`AppSettings.restoreRunningCommand` is a legacy decode shim that migration
clears, so doc comments naming it as the replay gate pointed at a field that is
nil on every migrated install. They now name the `rerun` launch mode, which is
what `GhosttyApp.restoreRunningCommand` resolves to.

The General tab comment described the toggle this branch replaced with a picker.

`zmx kill --window` was undocumented on both mirrored surfaces, leaving an
ambiguous cross-window prefix reporting the same text as a nonexistent target
with no remedy named.
Each artifact set has its own stamp, and setup.sh writes both through a symlink
while replacing the linked artifacts with local files. A worktree that rebases
onto a GHOSTTY_REV or ZMX_REV bump therefore leaves the main checkout claiming a
revision its artifacts were never built from unless the links go first.
A reboot kills every zmx daemon, and a restored live pane comes back as a bare
shell because live mode never captures a foreground command and never replays
one. Capture at both exits through the daemon-side resolver, then attach with a
create-only replay payload so zmx alone decides whether it is reattaching or
creating.
A launch strips the persisted argv into a transient slot and rewrites the file
with nil, so the exit capture is the only thing that refills it. Two launches
reach quit without consuming - one that requested live but fell back, and a
restored hidden split that is never shown - and both had nil written over the
argv the strip had already removed from disk.

Key the capture policy on the requested mode so a fallback launch still arms it,
and write an unconsumed pending value back at exit. Exit only: the on-demand
restore.capture must leave the persisted field nil while the slot stays armed,
or a later show replays it once and a crash replays the persisted copy again.

Also correct the bundled skill, whose reference.md and troubleshooting.md still
promised a fresh shell after a reboot and wrongly listed zmx kill as a replay
trigger - it closes a shown split or promotes a primary instead.
A session created with `session.new --command` lost its program on any
live launch that had to create the daemon. An exec-replacing command
leaves no readable foreground pid, so nothing is ever captured for it,
and the wrapped seed typed the creation command only for a fresh pane.
Switching into live is the common way to hit this, but it also fires
after a reboot, after a requested-live fallback launch, and when a
reopened window's daemon has since gone.

`attachCommand` now takes the durable line as a second source, with
three-way precedence: a captured argv is replayed when eligible and
yields a bare attach when the denylist refuses it, never falling
through to the creation command; only a genuinely absent capture
reaches that command; neither gives a bare attach.

`ZmxReplayScript` renders the line through a quoted `builtin eval --`
rather than the argv path, whose per-argument quoting would collapse a
multi-word line into one command name. `ZmxLaunch` passes the durable
command only for a restored pane with no capture, so a fresh pane's
`initial_input` cannot run it a second time. `splitInitialCommand` is
handled the same way as `initialCommand`.
Enabling Re-run commands took two restarts. Master decided at quit time
whether to capture, reading the setting live in applicationWillTerminate.
This branch built the exit closure once at launch from the frozen restore
decision, so a session launched in Fresh shells and switched to Re-run
captured nothing on that quit: the first restart opened plain shells and
the mode only took effect from the second.

The exit closure is now always installed and reads the configured mode
when it runs. Configured rerun or live captures through the existing
resolver-backed path; configured none explicitly clears every session's
captured and pending slots, because WindowAccessor treats a non-nil
closure as the last-window capture arm and a no-op there would bypass its
clear arm and persist stale slots.

`restore.capture` gates on the same configured mode and now passes the
zmx resolver, without which an active-live launch configured for rerun
would be admitted and then write nil over every wrapped pane. The frozen
launch decision still governs wrapping, daemon claims, reap and the
surface factories, since a pane is wrapped or not at creation.

Drops the requested-mode capture policy the change orphaned, and updates
the refusal text plus its two documentation copies to name the configured
mode.
Copilot AI lite review requested due to automatic review settings August 31, 2026 07:47
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying agterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: ce70f2f
Status: ✅  Deploy successful!
Preview URL: https://384440dc.agterm.pages.dev
Branch Preview URL: https://persistent-sessions.agterm.pages.dev

View logs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@umputun
umputun merged commit 4daca09 into master Aug 31, 2026
8 checks passed
@umputun
umputun deleted the persistent-sessions branch August 31, 2026 08:03
@paskal paskal mentioned this pull request Aug 31, 2026
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.

2 participants