Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,7 @@ list_skills = []
local_tty = []
local_computer_use = []
local_claude_codex_child_harnesses = []
agent_session_resume = []
# This feature is enabled in build.rs when compiling for platforms which
# have APIs for interacting with a local filesystem. It can be used to
# conditionally include dependencies that should only exist in such
Expand Down
6 changes: 4 additions & 2 deletions app/assets/bundled/bootstrap/bash_body.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1239,10 +1239,12 @@ esac
# Add a pattern to ignore in-band commands in shell history, while preserving the user's
# HISTIGNORE value which may been set in an RC file sourced above. It is important to
# ensure that this happens _after_ the user's RC files have been sourced.
# `warp_resume_agent_session` is the trailing-comment marker Warp appends to the
# invocation it runs when restoring a pane onto an agent's previous session.
if [[ ! -z $HISTIGNORE ]]; then
HISTIGNORE="*warp_run_generator_command*:$HISTIGNORE"
HISTIGNORE="*warp_run_generator_command*:*warp_resume_agent_session*:$HISTIGNORE"
else
HISTIGNORE="*warp_run_generator_command*"
HISTIGNORE="*warp_run_generator_command*:*warp_resume_agent_session*"
fi

# If the user has PROMPT_COMMAND set in their bootstrap scripts,
Expand Down
5 changes: 5 additions & 0 deletions app/assets/bundled/bootstrap/pwsh.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,11 @@ $null = New-Module -Name Warp-Module -ScriptBlock {
if ($line -match '^Warp-Run-GeneratorCommand') {
return $false
}
# Trailing-comment marker Warp appends to the invocation it runs when
# restoring a pane onto an agent's previous session.
if ($line -match 'warp_resume_agent_session') {
return $false
}
return $true
}

Expand Down
17 changes: 14 additions & 3 deletions app/assets/bundled/bootstrap/zsh_body.sh
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,15 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then
[[ "$1" != *"warp_run_generator_command"* ]]
}

# Returns exit code 1 if the given argument carries the agent-resume marker.
#
# Warp appends this marker to the invocation it runs when restoring a pane onto
# an agent's previous session. The marker is a trailing comment, so it is inert
# to the shell and identifies the line without a wrapper function.
_is_warp_agent_resume_command() {
[[ "$1" != *"warp_resume_agent_session"* ]]
}

# Note that this is very performance sensitive code, so try not to
# invoke any external commands in here.
warp_preexec () {
Expand Down Expand Up @@ -1212,16 +1221,18 @@ esac
POWERLEVEL9K_PROMPT_ADD_NEWLINE=false
fi

# Returns exit code 1 if the command starts with 'warp_run_generator_command'.
# Returns exit code 1 if the command starts with 'warp_run_generator_command',
# or carries the agent-resume marker.
#
# This is intended to be used as a zshaddhistory function to prevent in-band
# generators from being added to the zsh history file.
# generators and restored agent-session invocations from being added to the
# zsh history file.
# zshaddhistory functions.
#
# See https://zsh.sourceforge.io/Doc/Release/Functions.html for more context
# on the zshaddhistory hook.
_warp_zshaddhistory() {
_is_warp_generator_command "$1"
_is_warp_generator_command "$1" && _is_warp_agent_resume_command "$1"
}

# Register this zshaddhistory hook after the user's RC files have been sourced,
Expand Down
80 changes: 80 additions & 0 deletions app/resources/cli_agent_resume/agents.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Per-agent support for resuming a CLI agent session after Warp restarts.
#
# This file is embedded into the binary at build time and is the only place an
# agent is added: there is no per-agent restore path in code.
#
# It deliberately never names an executable. The binary comes from the detected
# agent's `CLIAgent::command_prefixes()`, so a declaration here cannot introduce
# a new process for Warp to run.
#
# Each declaration supplies:
# resume - the invocation shape that reattaches to a prior session.
# identifier - the value shape the recorded session id must satisfy.
# flags - the allowlist of flags carried over from the user's own
# invocation, each with the shape its value must satisfy.
#
# Every recorded value is untrusted: it comes from a local database file that
# any process running as the user can write, and the built string is handed to
# an interactive shell. A value that does not match its declared shape is
# dropped, never repaired, and every surviving value is shell-quoted. Widening a
# shape here widens what can reach the shell, so keep each character set as
# narrow as the CLI actually needs.
#
# Value shapes:
# boolean - the flag stands alone and carries no value.
# bare_token - ASCII letters, digits and `._-+:@`, not starting with `-`.
# path_like - `bare_token` plus `/`.
#
# A flag that chooses the agent's permission posture rather than describing the
# session is marked `permission_posture = true`. Those flags are the only ones
# bounded by a freshness window (`PERMISSION_POSTURE_FRESHNESS`): a recording
# older than the window resumes the same conversation without them, so a
# long-abandoned elevation is not revived by a restart. Mark every spelling that
# can elevate, including one that carries its posture as a value.
#
# Only flags that take at most one value belong here. A variadic flag cannot be
# told apart from a trailing prompt positional once the command line is
# tokenized, so carrying one risks turning the user's prompt into an argument.
# That rules out Claude Code's `--add-dir` and `--mcp-config`.
#
# Nor does a flag whose value names a file Warp cannot validate belong here. A
# shape checks the string, never what the file says, and a resume runs unattended
# at startup — so replaying such a pointer replays whatever the file has come to
# hold since. That rules out Claude Code's `--settings`, which can carry hooks
# and a permission mode: a code-execution pointer, and one the posture freshness
# window does not bound because it is not a posture flag.
#
# An agent absent from this file simply does not offer resume, which is the safe
# default and needs no code change:
# Gemini - its `--resume` has not been verified against a released CLI, and
# the in-repo harness still reports no conversation resume.
# WarpTui - the session id it reports over OSC is a process-local entity id
# rather than the token its `--resume` accepts, so a resume built
# from what we record could never reattach.

[agents.Claude]
# Verified against Claude Code 2.1.227: `claude --resume <session-id>`.
resume = { form = "flag", flag = "--resume" }
identifier = { shape = "bare_token", max_length = 128 }

# `--fork-session` and `--session-id` are deliberately absent: the first asks
# for a new session id when resuming and the second starts a new session
# outright, so either one would defeat the resume it rode in on.
[agents.Claude.flags]
"--model" = { shape = "path_like", max_length = 128 }
"--permission-mode" = { shape = "bare_token", max_length = 32, permission_posture = true }
# The permission posture the user chose. Carried because a pane that comes back
# re-prompting is not the session it replaced; never added when it was absent.
"--dangerously-skip-permissions" = { shape = "boolean", permission_posture = true }
"--strict-mcp-config" = { shape = "boolean" }
"--agent" = { shape = "bare_token", max_length = 64 }

[agents.Codex]
# `codex resume <session-id>`, the shape Warp's own headless driver already
# uses. Codex is not installed here, so only flags with in-repo evidence are
# allowlisted.
resume = { form = "subcommand", subcommand = "resume" }
identifier = { shape = "bare_token", max_length = 128 }

[agents.Codex.flags]
"--dangerously-bypass-approvals-and-sandbox" = { shape = "boolean", permission_posture = true }
4 changes: 2 additions & 2 deletions app/src/ai/blocklist/context_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ impl BlocklistAIContextModel {
.conversation_selection
.as_ref(ctx)
.is_conversation_fullscreen(ctx)
&& !user_block_completed.was_part_of_agent_interaction
&& user_block_completed.was_user_authored()
{
me.auto_attached_agent_view_user_block_ids
.push(block_id.clone());
Expand All @@ -180,7 +180,7 @@ impl BlocklistAIContextModel {
// If the block that finished was part of an agent interaction (i.e. LRC finishing),
// we should preserve input context.
if !FeatureFlag::AgentViewBlockContext.is_enabled()
&& !user_block_completed.was_part_of_agent_interaction
&& user_block_completed.was_user_authored()
{
me.reset_context_to_default(ctx);
}
Expand Down
2 changes: 1 addition & 1 deletion app/src/ai/blocklist/passive_suggestions/legacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ impl PassiveSuggestionsModel {
block_completed: &UserBlockCompleted,
ctx: &mut ModelContext<Self>,
) {
if block_completed.was_part_of_agent_interaction {
if !block_completed.was_user_authored() {
return;
}

Expand Down
2 changes: 1 addition & 1 deletion app/src/ai/blocklist/passive_suggestions/maa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ impl PassiveSuggestionsModel {
return;
}
if let BlockType::User(block_completed) = &after_block_completed_event.block_type
&& !block_completed.was_part_of_agent_interaction
&& block_completed.was_user_authored()
{
self.handle_user_block_completed(block_completed, ctx);
}
Expand Down
10 changes: 10 additions & 0 deletions app/src/ai/predict/next_command_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,16 @@ impl NextCommandModel {
self.zerostate_suggestion_info.as_ref()
}

/// Seeds the state a completed zero-state prediction would have left behind, so tests can
/// exercise the command-text telemetry arm without a server round trip.
#[cfg(test)]
pub(crate) fn set_zero_state_suggestion_info_for_test(
&mut self,
info: ZeroStateSuggestionInfo,
) {
self.zerostate_suggestion_info = Some(info);
}

pub fn clear_state(&mut self) {
self.next_command_state = NextCommandSuggestionState::None;
self.cached_zerostate_next_command_context = None;
Expand Down
47 changes: 45 additions & 2 deletions app/src/app_state.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;

use chrono::NaiveDateTime;
use pathfinder_geometry::rect::RectF;
use serde::{Deserialize, Serialize};
use warpui::platform::FullscreenState;
Expand All @@ -18,7 +19,8 @@ use crate::server::ids::{ServerId, SyncId};
use crate::settings_view::SettingsSection;
use crate::settings_view::environments_page::EnvironmentsPage;
use crate::tab::SelectedTabColor;
use crate::terminal::ShellLaunchData;
use crate::terminal::cli_agent_resume::RecordedFlag;
use crate::terminal::{CLIAgent, ShellLaunchData};
use crate::themes::theme::AnsiColorIdentifier;
use crate::workspace::WorkspaceRegistry;
use crate::workspace::tab_group::TabGroupId;
Expand All @@ -29,12 +31,52 @@ pub struct AppState {
pub windows: Vec<WindowSnapshot>,
pub active_window_index: Option<usize>,
pub block_lists: Arc<HashMap<PaneUuid, Vec<SerializedBlockListItem>>>,
/// Agent CLI state recorded per pane. Unlike the rest of this struct it is not written by a
/// snapshot save; it is read from its own table, which snapshot saves leave alone.
pub agent_sessions: Arc<HashMap<PaneUuid, RecordedAgentSession>>,
pub running_mcp_servers: Vec<uuid::Uuid>,
}

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct PaneUuid(pub Vec<u8>);

/// The agent CLI a pane was last observed running, recorded so a restart can offer to resume it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RecordedAgentSession {
pub agent: CLIAgent,
/// The session identifier the agent itself reported.
pub session_id: String,
/// The allowlisted flags of the invocation the user ran that matter when relaunching the
/// agent. Never the command line itself: replaying what the user typed would replay secret
/// placeholders and unexpandable aliases along with it.
pub flags: Vec<RecordedFlag>,
/// The directory the agent was running in. Recorded here rather than read back from the
/// pane snapshot so that eligibility can compare it against the directory the pane
/// actually restored into.
pub directory: PathBuf,
pub observed_at: NaiveDateTime,
}

/// Recorded agent sessions handed to pane restoration.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AgentSessionRestore {
pub sessions: Arc<HashMap<PaneUuid, RecordedAgentSession>>,
/// The panes that own the identifier they recorded, resolved across every window before the
/// first one is created. Panes left out of it recorded an identifier another pane won.
pub claimed_panes: Arc<HashSet<PaneUuid>>,
/// Mid-session restores (a tab added from a snapshot) reach the same restore path as
/// startup, and resuming an agent there would be wrong, so the startup pass says so
/// explicitly instead of leaving it to be inferred.
pub is_startup_restore: bool,
}

impl AgentSessionRestore {
/// Whether `pane_uuid` is the pane that gets to resume the identifier it recorded.
pub fn owns_recorded_identifier(&self, pane_uuid: &PaneUuid) -> bool {
self.claimed_panes.contains(pane_uuid)
}
}

/// Wrapper for persisting agent management filters to restore.
#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersistedAgentManagementFilters {
Expand Down Expand Up @@ -392,6 +434,7 @@ pub fn get_app_state(app: &AppContext) -> AppState {
windows,
active_window_index,
block_lists: Default::default(),
agent_sessions: Default::default(),
running_mcp_servers: Vec::new(),
}
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,8 @@ fn enabled_features() -> HashSet<FeatureFlag> {
FeatureFlag::BackgroundComputerUse,
#[cfg(feature = "local_claude_codex_child_harnesses")]
FeatureFlag::LocalClaudeCodexChildHarnesses,
#[cfg(feature = "agent_session_resume")]
FeatureFlag::AgentSessionResume,
#[cfg(feature = "team_api_keys")]
FeatureFlag::TeamApiKeys,
#[cfg(feature = "named_agents")]
Expand Down
2 changes: 2 additions & 0 deletions app/src/launch_configs/launch_config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ fn single_tab_snapshot(root: PaneNodeSnapshot) -> AppState {
}],
active_window_index: Some(0),
block_lists: Default::default(),
agent_sessions: Default::default(),
running_mcp_servers: Default::default(),
}
}
Expand All @@ -65,6 +66,7 @@ fn multi_tab_snapshot(active_tab_index: usize, tabs: Vec<TabSnapshot>) -> AppSta
}],
active_window_index: Some(0),
block_lists: Default::default(),
agent_sessions: Default::default(),
running_mcp_servers: Default::default(),
}
}
Expand Down
Loading