Resume a pane's CLI agent session after a restart - #14956
Conversation
…history Extends the existing per-shell suppression that already excludes Warp's in-band generator commands from the real history file so it also drops the invocation Warp runs when restoring a pane onto an agent's previous session. The three shells share one marker, `warp_resume_agent_session`, appended to the invocation as a trailing comment: comment syntax is identical in zsh, bash, and PowerShell, so the marker is inert to the shell, and it contains no `:` that would break bash's HISTIGNORE separator. The leading-space route is deliberately not used — Warp unsets hist_ignore_space and HISTCONTROL after bootstrap so the user's own history options govern. This is inert until a resume actually runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5
Adds the cargo feature, the FeatureFlag variant, and the compile-time to runtime bridge, registered in LOCAL_FLAGS and RUNTIME_FEATURE_FLAGS so the whole agent-session-resume path is switchable at runtime and off by default. Deliberately not added to the crate's `default` feature list. Follows the FeatureFlag::LocalClaudeCodexChildHarnesses registration shape across all five sites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5
A post-registration `session_start` was discarded outright, so a pane whose user started a second conversation kept reporting the first conversation's identifier. The CLI-agent footer already misattributes status because of it. `CLIAgentSessionHandler::handle_event` now receives the identifier the session currently holds, and `DefaultSessionListener` drops a `session_start` only when it carries no identifier or repeats the recorded one. An event reporting a genuinely new identifier reaches `update_from_event`, whose existing latch adopts it without ever overwriting a known identifier with `None`. Only the listener needed changing. The view's early return does not block the event: the listener holds its own subscription to the same dispatcher and receives every subsequent notification. Removing that early return would process each post-registration event twice — duplicate `SessionUpdated`, duplicate `CLIAgentPluginDetected` telemetry, and a re-fired rich-input auto-open. `register_listener`'s existing-session path already adopts a newly reported identifier and is not on this path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5
…able Adds an `agent_sessions` table keyed by pane uuid, holding the agent kind, the identifier the agent itself reported, the resume-relevant flags, the directory, and when the state was observed. The table follows the `blocks` precedent and is deliberately absent from `save_app_state`'s delete list. A row on a snapshot-rebuilt table such as `terminal_panes` would be erased by every full save — including the save that restore itself triggers per pane at shell bootstrap — so the value could never survive to be read back. Two tests pin that guarantee, and both were shown to fail when the table was added to the delete list. The directory is recorded here rather than read back from the pane snapshot so eligibility can later compare it against the directory the pane actually restored into, without two columns claiming the same fact. It is stored as a BLOB via the existing path encoding so non-UTF-8 paths survive. `agent_kind` and `flags` are nullable on purpose: the writer degrades an unserializable value to NULL rather than failing, and NOT NULL would turn that degradation into a constraint error that aborts the whole snapshot transaction. The read side treats either being absent or unparseable as "no recording" rather than resuming from a half-known invocation. `AgentSessionRestore` carries the loaded map plus an explicit startup-pass flag, because `restore_pane_leaf` is also reachable from `add_tab_with_pane_layout`; only `open_from_restored` sets it. Nothing emits the write event yet and the restore call site only logs — capture, eligibility, and launching are separate units. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5
…ation Adds an embedded TOML declaration of how each agent CLI reattaches to a prior session, plus the builder, validator, and capture-side flag extractor that read it. Adding an agent is a file edit; there is no per-agent restore path. The declaration never names an executable — the binary comes from the detected agent's `command_prefixes()` — and a key that tries to name one is a load error. Two invocation shapes are modelled because both exist in the wild: a flag taking the identifier (`claude --resume <id>`) and a subcommand taking it (`codex resume <id>`). Every recorded value is treated as untrusted: the store is a local database file any process running as the user can write, and the built string is parsed by an interactive shell. Two independent barriers keep it safe. Each declared shape is a character allowlist rather than a metacharacter denylist, so whitespace, `;`, `$`, backticks, globs, newlines, quotes and non-ASCII lookalikes are all rejected by construction; a failing value drops its flag rather than being repaired. Every surviving value is then unconditionally single-quoted, and quoting refuses a value containing a single quote — the one character that could end the wrapping. A resume pointer that fails its shape yields no command at all rather than a partial line. The builder adds no flag of its own, unlike the headless driver builders which attach approval bypasses unconditionally. Flags are emitted before the identifier so an agent taking a trailing prompt positional cannot swallow them. Only Claude and Codex are declared. Gemini's resume is unverified against a released CLI, and WarpTui reports a process-local entity id over OSC rather than the token its `--resume` accepts, so a resume built from what we record could never reattach. Claude's variadic `--add-dir` and `--mcp-config` are excluded because a variadic flag is indistinguishable from a trailing prompt positional once tokenized. The built invocation carries the `warp_resume_agent_session` trailing-comment marker the shell bootstrap files already suppress from history. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5
…ds anything Adds the two-stage gate that decides, silently, which restored panes may reattach to a recorded agent session. Duplicate identifiers are resolved first, as a pure function over the loaded pane-uuid map plus the window layout, before any window is created. Each window is its own `add_window` call and the active one is created last, so a first-wins-over-restore-order rule would hand a shared identifier to a background pane. Claims rank by landing window, then visible window, active tab, focused leaf, newest observation, and finally pane-uuid bytes as a stable tie-break. The per-leaf gate then returns a typed reason rather than a bool, so each rejection is separately reportable. Rejection is always silent: no marker, badge, message, or toast, because explaining a skipped resume would put a Warp-specific artifact into a pane the user expects to look like their own terminal. Remoteness and shared-session viewing cannot be read from `CLIAgentSession` — that is a live runtime struct which does not exist during startup restore, and the recorded row carries no such column. The only restore-time evidence is the snapshot itself: a cwd reaches it solely via `pwd_if_local`, and `input_config` is left unset only by the viewer branches of `TerminalPane::snapshot`. Each check names its source. Because the save path also drops the cwd for a local pane whose directory vanished before the last save, that variant stands for both unresumable cases; both restore as a shell, which is the acceptance criterion. Directory comparison canonicalizes both sides, since raw `PathBuf` equality reports a false mismatch for symlinked paths such as macOS `/tmp` against `/private/tmp`. The claim check runs last so a pane that could not have resumed anyway does not take the identifier from one that could. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5
Wires the capture side: a per-pane subscriber, filtered on the terminal view id
it captured at subscription time, records the agent kind, the newest identifier
the agent reported, the allowlisted flags of the user's own invocation, the
directory, and when it was observed. `SaveAgentSession` becomes
`SetAgentSession { session: Option<..> }` so a save and a clear share one order
and one coalescing key.
Only allowlisted, alias-resolved flags are recorded — never the raw command
line, and never the session context. The capture path reads the sessions model
solely through a new accessor returning the agent and identifier, so there is
no reachable path to the user's prompts, the agent's replies, a summary, a tool
preview, or a draft. A test sets every one of those fields to a marker,
destructures the recorded struct exhaustively so a new field cannot be added
without review, and asserts none of it is persisted. Command text is read only
through the secrets-obfuscated accessor; obfuscated cells render as `*`, which
the declared value shapes reject at build time.
Ordering follows one total order: the send happens on the main thread from the
subscription callback rather than from independent spawned tasks, which have no
ordering guarantee and could land a newer identifier before an older one. Volume
is cut twice — unchanged observations are dropped at the source, and the writer
coalesces per pane uuid — because the session event fires once per tool call,
not once per turn, against a channel a blocking main-thread sender also uses.
Coalescing never hoists a snapshot past a capture write.
State is cleared only when the agent ends while its pane is still attached.
`detach` clears the attached flag before `remove_session` runs, so hidden-for-
close, closed, and teardown detaches are invisible to capture — which is what
keeps an undone close resumable. A suspended agent completes a background block
rather than a user block, so it never ends its session and keeps its state.
`resolve_command_aliases` is factored out of `CLIAgent::detect` unchanged,
because flag capture needs the alias-expanded text and `detect` returns only the
agent. Panes are not unsubscribed on detach: subscriptions are keyed by the
subscribing PaneGroup view, so unsubscribing one pane would kill its siblings'
capture.
`RecordedAgentSession.flags` becomes `Vec<RecordedFlag>` to match what the
extractor produces and the resume builder consumes, avoiding a lossy re-parse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5
… command An eligible restored pane now executes its agent's resume invocation instead of coming up as a bare shell, without touching the user's input buffer, their history, or the one-time state Warp reserves for a user's first command. Three layers, because a command-source variant alone reaches almost nothing. A new `AgentSessionResume` source carries `should_add_command_to_history: false`, which gates both Warp's history and the persisted commands table, and suppresses the arm that reports executed command text as telemetry. A block-level `is_warp_authored` marker then covers everything that reads `was_part_of_agent_interaction` — that value is derived from `ai_metadata` and is structurally false for a resume, so a source variant cannot reach it. `was_user_authored()` becomes the single choke point every consumer reads. The shell's own history file is already handled by the bootstrap marker. Three consumer classes needed the marker, not two. Passive suggestions have a second live implementation beyond the one the plan listed, which would otherwise have produced suggestions from a resume. The pane's zero-state affordance matches on the block type with a wildcard rather than on agent metadata, so it is reachable by neither the source nor the metadata; it needed the block marker specifically, and gained the sibling test file it never had. The consent-banner site receives only a block id, so authorship is threaded to it explicitly. The invocation is held as data and dispatched from the existing shell-bootstrap gate rather than staged in the user's editor: `set_pending_command` inserts without clearing and then executes the whole buffer. The arm is taken before execution, so a failure is left exactly as a terminal leaves any failed command — no retry. A pane holding user text, a pane the user has typed into, and a pane with a queued launch-config command all refuse the injection. Permission-posture flags are carried only when the recording was observed within a bounded window, now including the valued `--permission-mode`, not just boolean bypass switches. A recording dated in the future is treated as stale, so winding the clock back cannot revive an expired elevation. Past the window the pane still resumes, just without the posture. The 12-hour value is provisional and doc-commented as a rollout decision; a newly declared posture flag fails the suite until it is acknowledged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5
…s gone for good
Recorded rows are keyed by pane uuid and nothing removed them when a pane was
permanently discarded, so they accumulated for panes that will never return.
Deletion hangs off the same pane-lifecycle branch that already purges a gone
pane's per-uuid rows — `DetachType::Closed`, behind the same save-session guard —
rather than off `save_app_state`, whose snapshot transaction is exactly the
coupling the recorded state exists outside of. It is deliberately not folded into
`delete_blocks`, which is also reached from clearing a block list and must not
touch the agent row.
`Closed` is reached only from the undo-stack discard paths. Closing a tab or a
window detaches as `HiddenForClose`, so an undone close still owns its recording
and still resumes, and app teardown clears nothing.
This corrects a factual error in the capture unit's test: `clean_up_panes` is not
app teardown — teardown reaches `detach_panes` and hides for close — so that
assertion was pinning the opposite of what permanent removal requires. It now
lives with the permanent-removal case, and the hide-for-close assertion carries
the note that window close and teardown both take that path.
The load-time sweep is a secondary safeguard only: it drops rows for pane uuids
the saved snapshot does not restore, runs once per process before any pane
exists, and is non-fatal so a locked or read-only database still restores every
window.
`SetAgentSession { session: None }` is reused rather than adding an event, so
everything a pane says about its agent stays in one order and inherits the
existing per-pane coalescing — which is what stops a stale save from
resurrecting a deleted row. `detach` clears the attached flag first, so the
clear is always the pane's last word.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5
…session Adds `AgentSessionResume.PaneRestore.Outcome`, emitted once per restored pane that carried recorded state, so the feature's field behavior is measurable before the flag is promoted. The payload is four closed values — agent kind, outcome, whether permission posture flags were carried, and a coarse age band. No `String` reaches it at all, so no invocation text, flag value, session identifier, or path can be carried; `contains_ugc` is false. A test stuffs a recording with sensitive markers, destructures the event exhaustively so a later field cannot slip through unreviewed, and asserts the serialized payload contains no marker, no `/`, and no `--`, with the key set pinned exactly. A pane that was not running an agent reports nothing, structurally as well as by outcome mapping — otherwise every ordinary pane would emit. The age bands put R22's provisional 12-hour window on a band edge rather than inside a band, so the distribution can answer what moving the window to 6h or 24h would cost rather than only how the guess did. Bands close at their upper edge, matching the freshness rule itself, so the carrying population is exactly the bands up to the window. A recording dated in the future gets its own band instead of being folded in with the oldest, where it would read as false evidence for a shorter window. Posture-carried is reported only when the pane actually resumed: the posture is computable for every pane, including ones that armed nothing, so reporting it verbatim would claim flags were carried where nothing ran. The field means the recording was fresh enough for its flags to ride along, not that flags existed and survived validation — the builder drops invalid flags internally and the call site cannot see which survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5
Three pieces of waste found reviewing the feature, plus dead code. The capture path did a grid walk with secret redaction, an alias resolution, a flag extraction, and a working-directory stat on every firing of an event the codebase documents as firing once per tool call — hundreds of times per agent task — only for a same-value guard a few lines later to discard nearly all of it. The pair naming the conversation holds for the whole task, so the burst now settles on a map lookup and a short string compare, and the expensive read runs only when that pair actually changes. The original guard stays as the final check, since flags and directory can move while identity does not. Startup paid `is_dir` and a canonicalization per restored pane with a recorded session even when the feature flag was off — which is every user today. The telemetry that follows is independently gated on the same flag, so with it off that work had no observable effect at all. The gate now runs before the filesystem work, and deliberately at the call site rather than inside the eligibility function, so the unit tests that exercise the gate directly still see real verdicts. The restored directory was stat-ed twice: once by the caller filtering on `is_dir`, then again inside the shared resolver. Verification and canonicalization are now separate, so the recorded directory — whose existence is genuinely unknown and is what R15 turns into an ineligibility reason — is still checked, while the already-verified side is only canonicalized. Both sides still canonicalize, which is what makes symlinked paths such as macOS `/tmp` against `/private/tmp` compare equal. `AgentSessionRestore::recorded_on_startup` is removed: it was superseded within this branch by the eligibility gate, which cannot use it because it needs the absent-recording and not-a-startup-pass cases as distinct outcomes that a single collapsed `Option` cannot express. Nothing outside tests called it. A doc block describing the command builder had drifted onto the neighbouring posture-flag accessor; it is moved back, and that accessor now says why it exists so it is not deleted as unused — it is what forces a newly declared permission-posture flag to be acknowledged before it can ship. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5
…tory gaps Findings from a multi-reviewer pass over the whole feature. Prompt text could become a permission grant. Flags were extracted by splitting the command on whitespace, so flag-shaped words inside a quoted prompt — for instance asking an agent to `use --permission-mode bypassPermissions` — were recorded as if the user had chosen that posture, and the resume then relaunched with an elevation they never asked for. The justification for splitting on whitespace covered allowlisted *values*, which are all bare tokens; it did not cover words in a positional. Tokenizing the way a shell does keeps a quoted prompt one token, and extraction now stops at the first positional and at an end-of-flags marker, so nothing past the prompt can be read as a flag. `--settings` is no longer carried. Its value names a file Warp cannot validate and that file can carry hooks and a permission mode, so replaying it started unattended work at app startup that the posture freshness window does not bound — the same reason `--add-dir` and `--mcp-config` are already excluded. Capture was not behind the feature flag. Every user on a default build was recording agent state for a feature that cannot run, so the gate now matches the restore side, on both the write and the delete. A failed read of the recordings aborted the whole restore. The `?` sat two lines below a comment promising that housekeeping must never cost the user their session, and it turned an unreadable optional table into the loss of every window, tab and pane. It now degrades to no recordings, like the purge above it. Fish users got no suppression at all. The marker is matched by three bootstrap scripts and fish is not one of them, so the invocation entered their history. Fish omits leading-space commands from history as default, non-configurable behavior, which is what Warp already relies on for its own in-band commands, so the line is prefixed for that shell only. A test now asserts every shell that suppresses Warp's own commands also suppresses this one — the coupling whose absence let fish be missed. Also: a claim winner that cannot resume no longer strands a conversation an eligible pane could have taken; an internal invocation no longer raises a user-facing toast; and three `AGENTS.md` violations are fixed — two doc comments naming their callers, path qualifiers over imports, and a wildcard match arm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5
|
Every PR must be linked to a same-repo issue before Oz can review it. Next step: open or find a same-repo issue describing this change, then link it to this PR by adding See the contribution guidelines for the full readiness model. Powered by Oz |
There was a problem hiding this comment.
Every PR must be linked to a same-repo issue before Oz can review it.
Next step: open or find a same-repo issue describing this change, then link it to this PR by adding Closes #123 to the PR description (or using the "Development" sidebar on GitHub). A maintainer will mark the issue ready-to-implement when it is ready. Once it is marked, comment /oz-review to re-trigger review.
See the contribution guidelines for the full readiness model.
Powered by Oz
Description
After a restart, a pane that had a CLI agent in the foreground comes back with that agent live on the same conversation instead of a bare shell.
Warp records, per pane, the agent kind, the session identifier the agent itself reported, an allowlisted set of resume-relevant flags from the user's own invocation, the directory, and when the state was last observed. At startup it resolves eligibility and runs the agent's own resume invocation. Panes it cannot resume safely come back exactly as they do today, silently.
Everything is behind
FeatureFlag::AgentSessionResume, off by default, except two pieces that stand on their own: a precursor bug fix in session tracking, and the shell-history suppression (inert until a resume actually runs).Design decisions worth knowing:
blocksprecedent, deliberately outsidesave_app_state's delete list. A row on a snapshot-rebuilt table such asterminal_paneswould be erased by every full save — including the save that restore itself triggers per pane at shell bootstrap — so it could never survive to be read back. Two tests pin that, and both fail if the table is added to the delete list.Agent support is a data file. Only Claude and Codex are declared. Gemini's resume is unverified against a released CLI, and the Warp TUI reports a process-local entity id over OSC rather than the token its
--resumeaccepts, so a resume built from what we record could never reattach. Adding an agent is an edit toapp/resources/cli_agent_resume/agents.toml.Linked Issue
Closes #14960.
ready-to-specorready-to-implement. — Restore a pane's CLI agent session after restarting Warp #14960 was filed alongside this PR and has not been triaged yet. It is a feature request, so the readiness path runs throughready-to-specand a spec PR; this branch is the implementation that exists behind an off-by-default flag, offered as evidence rather than as a shortcut past that step.Testing
Roughly 490 tests in the feature's scope pass, alongside the full workspace suite (11,066 passing; the 5 failures are pre-existing SSH integration tests that require the gcloud SDK and a remote host, and CI excludes them for fork PRs).
cargo clippy --workspace --all-targets --tests -- -D warnings,cargo fmt --check, and./script/check_no_inline_test_modulesare clean. The migration was verified to apply as the last in the full chain and to revert cleanly, dropping only its own table.Coverage worth calling out: hostile values (
;,$(…), backtick,&&,|,*, newline, quote, leading dash, NUL, and more) each assert exact string equality with the bare invocation, so no fragment and no empty--flag ''residue can survive. A privacy test stuffs every conversation field with a marker, destructures the recorded struct exhaustively so a new field cannot be added without review, and asserts none of it is persisted../script/runNot manually tested, and this needs a human before the flag is promoted. The plan's rollout gates are deliberately unmet here: for every declared agent, confirm by hand that a resumed session performs no tool call and issues no model request before the first user message; measure cold-start time and memory on a profile with at least eight eligible agent panes; measure the capture-side write rate against a real agent session doing tool calls; confirm history suppression by hand in zsh, bash, PowerShell and fish; and pick R22's real freshness window from field data (the shipped 12 hours is a documented placeholder, and the telemetry age bands are deliberately edged at it so the distribution can answer what 6h or 24h would cost).
Review notes
A multi-reviewer pass over the finished feature found several things worth flagging, all fixed in the final commit:
use --permission-mode bypassPermissionsrecorded that posture as if the user had chosen it, and the resume relaunched elevated. The justification for splitting on whitespace covered allowlisted values — all bare tokens — but not flag-shaped words inside a positional.One deliberate divergence from the plan, for the author to confirm: the plan directed carrying every allowlisted flag and explicitly accepted
--mcp-configstarting MCP servers unattended at startup. The shipped allowlist excludes--add-dir,--mcp-configand--settings. The first two are variadic, and after tokenisation a variadic flag's tail is indistinguishable from a trailing prompt positional, so carrying them would record the user's prompt as a directory.--settingsnames a file Warp cannot validate that can itself carry hooks and a permission mode. This is arguably the safer outcome but it is not what was specified.Known gaps: a resume that fails at runtime has no direct test (it needs a real shell); the multi-pane case asserts each pane is armed with its own invocation rather than executed, because the restore harness has no live shell; and the wasm CI gate was not run locally — every import made unconditional here is already used unconditionally elsewhere in
app/src, but CI is authoritative.Separately: nothing in this repo enforces that
crates/persistence/src/schema.rsis regenerated whencrates/persistence/migrations/changes. This branch briefly shipped a commit where they disagreed and the whole workspace failed to compile.Screenshots / Videos
To be attached.
Agent Mode
🤖 Generated with Claude Code
https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5