feat: per-session restore-command override - #271
Merged
Conversation
The plan for a per-session, per-pane override that pins what a pane restores on the next launch, from GitHub discussion #264. It covers the model state, the CommandRestore precedence, the surface factories, the control surface, the tree read-back, the agtermctl subcommand, and the five keep-in-sync documentation surfaces.
ControlServer+SessionActions.swift held both the per-session arms and the app-global ones, pushing past the file-size budget with the session.restore arm still to come. Move the app-global arms — the tree projection, sidebar visibility/mode and expand/collapse, keymap and config reload, themes, and the quick terminal — into a sibling ControlServer+AppCommands.swift, matching the existing +SurfaceIO/+WindowCommands split. Pure code motion apart from dropping the thin wrappers the sidebar arms had accumulated: the implementations now carry the ControlActions names directly.
The restore-running-command feature re-runs whatever a pane had in the foreground at the last clean quit. That is wrong for a non-idempotent command such as `claude --resume <id> --fork-session`, which mints a new session on every restart, and there is no way to say "restore THIS instead". Add the persisted per-pane override the control surface will drive. Session gains a sticky `restoreCommand`/`splitRestoreCommand` pair plus transient pending slots, Snapshot and WindowLibrary carry them across a quit, and only an app-bootstrap restore (`restore(from:launchRestore:)`) arms the pending slot, so a mid-process window reload or Reopen Closed Item never executes anything. A split hidden at the last quit drops its pin, the same rule closeSplit applies when the pane goes away. CommandRestore.restorePlan takes a RestoreInputs struct with the override, which wins over both the captured foreground and the session's own initialCommand and bypasses the denylist (it names its command deliberately); CommandRestore.restoreInput is the split-pane analogue. Both surface factories consume the pending slot and clear it, so a second surface for the same pane comes up as a plain shell. AppStore.setRestoreCommand is the single mutation point: it persists immediately (the pin must survive a SIGKILL) and reports whether the write landed, rolling back in memory when it did not, so a caller cannot acknowledge a pin that never reached disk. The tree node gains `restoreCommand`/`splitRestoreCommand`, reported from the persisted state so a read after the override fired still shows what stays pinned.
Expose the per-pane override over the control channel. The host-free ControlDispatcher owns the parse and validation: mode `set|none|clear` maps to ControlRestoreOverride, `set` requires a non-empty --command capped at 1024 UTF-8 bytes (the value persists in windows/<id>.json), and the pane parse rejects `scratch` — the scratch terminal is never restored. Unlike session.status, an unresolvable --pane-id with no explicit --pane is an error rather than a silent main-pane default, since pinning the wrong pane's command persists. ControlServer resolves the pane against the session's live surfaces and drives AppStore.setRestoreCommand, refusing to acknowledge a write that did not reach disk. `agtermctl session restore` takes the command as a positional argument with --none/--clear as the two other forms, mutually exclusive.
ControlAPIUITests drives the command surface against the running app: the tri-state read-back on tree (a pinned command verbatim, --none as an empty string, --clear omitting the key), the --pane-id fallbacks (an empty token counts as absent, an unresolvable one with an explicit --pane falls back to it), a token following a promoted survivor into the main slot, the rejections (the right pane of a split-less session, a scratch token, an unresolvable token with no --pane), and that the app-global restore.clear leaves a per-session pin standing. RestoreCommandUITests covers the launch path across real relaunches: the override beats the captured foreground, stays pinned across two relaunches, --none brings the pane back as a plain shell, --clear falls back to capture, nothing fires with the restore setting off, the split pane pins separately, a hidden split's pin never lands on a fresh split, reopening a window does not arm anything, and the pin survives a force quit.
Cover the new command everywhere the control API is described: the control-api rule (the catalog entry, the pane-resolution divergence from session.status, the read-back fields), README and site/docs.html (the restore-limitations section and the 62-command count), site/commands.html (the per-command entry with its arguments and tree read-back), and the bundled agent skill — SKILL.md, the reference entry, a SessionStart-hook recipe in examples.md, and the troubleshooting entry for a pin that does not fire.
Deploying agterm with
|
| Latest commit: |
4fe05c6
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://9af8a135.agterm.pages.dev |
| Branch Preview URL: | https://session-restore-override.agterm.pages.dev |
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.
per-session, per-pane restore-command override, so a non-idempotent foreground command can be pinned to a corrected invocation instead of being re-run verbatim.
from discussion #264: restore re-runs the captured foreground argv as-is, which is right for
claude --resume <uuid>and wrong forclaude --resume <uuid> --fork-session. Every restart mints a new claude session, so the one you were actually working in never comes back and near-identical transcripts pile up. Nothing in agterm can know which commands are idempotent, but it's the only layer where the user or a hook can fix it.what this adds
session.restorepins a shell line per session, per pane. Write-now, consume-next-launch, so setting it never touches the running session:tri-state on one field: absent = auto-capture (today's behavior, untouched),
""= plain shell,"cmd"= run it. Read back ontreeasrestoreCommand/splitRestoreCommand.ownership flips to whoever sets it. A
SessionStarthook knows the live child uuid and rewrites the pin on every start, so the next restart reattaches instead of forking. Write once and forget and it stays pinned to a stale uuid, that's the tradeoff, and it's why this is a hook-driven override rather than a setting. Recipe is inexamples.md.design bits worth a look
the persisted field and the thing the surface factory reads are deliberately separate.
restoreCommandis persisted and sticky;pendingRestoreCommandis transient, seeded only by an app-bootstrap restore, and take-and-nil'd by the factory. That's what keeps a socket write from executing in the same run, and stops a pin firing on a mid-process window reopen, Reopen Closed Item, a grace-window undo, or a fresh Cmd-D split.a split hidden at quit drops its pin. It comes back with
hasSplit == falseand is never rebuilt, so keeping the pin would leave a valuetreereports but no write can clear, and it would later fire into an unrelated new split.session.restoreis the one store write whose save failure is reported rather than swallowed. Acking a--clearthat never reached disk would leave the old command firing on every launch, so a failed write rolls back and answersok: false.obeys the
restoreRunningCommandsetting, bypassesrestore-denylist.conf. The denylist is a basename heuristic for blind capture; an override names its command deliberately.pinned values are shell code stored in the window state file and readable via
tree, so they must not carry secrets. Called out in the docs.tests
1666 host-free tests plus 95 XCUITests, including two-relaunch stickiness (the only thing that proves the pin isn't consumed off the persisted field), force-quit durability, the split-pane branch, and the hidden-split no-refire case.
not included
settings-level pattern skip/rewrite rules, the second idea in #264. Pattern-matching other tools' argv is a rules engine agterm shouldn't own, and the skip half already exists as
restore-denylist.conf.first commit is a prep refactor splitting the app-global control arms out of
ControlServer+SessionActions.swift, which was at 998 of the 1000-line limit.Related to #264