Skip to content

feat: per-session restore-command override - #271

Merged
umputun merged 7 commits into
masterfrom
session-restore-override
Jul 22, 2026
Merged

feat: per-session restore-command override#271
umputun merged 7 commits into
masterfrom
session-restore-override

Conversation

@umputun

@umputun umputun commented Jul 22, 2026

Copy link
Copy Markdown
Owner

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 for claude --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.restore pins a shell line per session, per pane. Write-now, consume-next-launch, so setting it never touches the running session:

agtermctl session restore "claude --resume $sid" --target "$AGTERM_SESSION_ID" --pane-id "$AGTERM_PANE_ID"
agtermctl session restore --none     # this pane restores nothing
agtermctl session restore --clear    # back to auto-capture

tri-state on one field: absent = auto-capture (today's behavior, untouched), "" = plain shell, "cmd" = run it. Read back on tree as restoreCommand / splitRestoreCommand.

ownership flips to whoever sets it. A SessionStart hook 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 in examples.md.

design bits worth a look

the persisted field and the thing the surface factory reads are deliberately separate. restoreCommand is persisted and sticky; pendingRestoreCommand is 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 == false and is never rebuilt, so keeping the pin would leave a value tree reports but no write can clear, and it would later fire into an unrelated new split.

session.restore is the one store write whose save failure is reported rather than swallowed. Acking a --clear that never reached disk would leave the old command firing on every launch, so a failed write rolls back and answers ok: false.

obeys the restoreRunningCommand setting, bypasses restore-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

umputun added 7 commits July 21, 2026 18:33
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.
Copilot AI review requested due to automatic review settings July 22, 2026 00:50

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.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying agterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4fe05c6
Status: ✅  Deploy successful!
Preview URL: https://9af8a135.agterm.pages.dev
Branch Preview URL: https://session-restore-override.agterm.pages.dev

View logs

@umputun
umputun merged commit cb8cf1c into master Jul 22, 2026
6 checks passed
@umputun
umputun deleted the session-restore-override branch July 22, 2026 00:55
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