Live session restore backed by embedded zmx - #515
Merged
Conversation
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/.
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.
Deploying agterm with
|
| Latest commit: |
ce70f2f
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://384440dc.agterm.pages.dev |
| Branch Preview URL: | https://persistent-sessions.agterm.pages.dev |
Closed
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.
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
Mode changes apply after restarting agterm.
Live is never selected automatically. When a settings file has no
restoreModekey,AppSettings.effectiveRestoreModeresolves it through the legacyrestoreRunningCommandboolean:truebecomes Re-run commands,falseor 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.shbuilds zmx fromneurosnap/zmxat a pinned rev. An Xcode phase copies it toContents/MacOS/zmxand signs it ad hoc with the hardened runtime.Live needs a zsh login shell. The wrapper points
ZDOTDIRat Ghostty's zsh integration and chains the user's own throughGHOSTTY_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.modereads and writes the policy.zmx.list,zmx.pruneandzmx.killinspect and manage the daemons behind live panes. Arguments and read-back fields are insite/commands.html.Non-live behaviour changes
Three things differ from master even when Live is never selected:
Compatibility
No public
agtermCoresymbol 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.Commandgains four cases, which can break an external exhaustive switch; the knownagterm-linuxconsumer does not switch overCommand.Related to discussion #264: live mode avoids re-running the non-idempotent commands discussed there on an ordinary app restart, without closing the discussion.