feat: expand Galaxy agent and remote tooling

Add Wormhole remote helpers, provider and agent improvements, filesystem diagnostics, model metadata support, and schema-aware settings IntelliSense.
This commit is contained in:
2026-08-23 13:55:47 -05:00
parent f17642fc62
commit 7c106eecd5
147 changed files with 2208 additions and 1514 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ When the Warp notification plugin can't be auto-installed (SSH session, or a pre
## Chip Visibility Fix (Remote Sessions)
`should_show_install_plugin_button` hides the chip when `manager.is_installed()` returns true. But `is_installed()` reads the **local** filesystem (`~/.claude/plugins/installed_plugins.json`), not the remote machine's. In any remote session (warpified SSH, legacy SSH, Docker via SSH) where Claude Code runs on the remote, this check is wrong:
`should_show_install_plugin_button` hides the chip when `manager.is_installed()` returns true. But `is_installed()` reads the **local** filesystem (`~/.claude/plugins/installed_plugins.json`), not the remote machine's. In any remote session (wormholed SSH, legacy SSH, Docker via SSH) where Claude Code runs on the remote, this check is wrong:
- Plugin installed locally but not on remote → chip hidden, user stuck with no instructions
@@ -114,4 +114,4 @@ Failure state is not persisted. A new terminal session starts fresh in Mode 1 (a
- **User installs plugin manually mid-session (without using the chip):** The listener will connect on next `SessionStart` event, chip disappears automatically.
- **User clicks chip in Mode 2 then installs manually:** Modal stays open until dismissed. Chip disappears on next render once listener is present.
- **Multiple terminal tabs with same agent:** Each tab has its own `AgentInputFooter` with independent failure tracking. This is correct — one tab's failure shouldn't affect another.
- **Warpified SSH (tmux wrapper):** Even though the local filesystem is accessible via tmux, the agent runs on the remote machine. The `is_remote` flag is set for all SSH sessions (warpified or legacy), so Mode 2 applies to all remote sessions.
- **Wormholed SSH (tmux wrapper):** Even though the local filesystem is accessible via tmux, the agent runs on the remote machine. The `is_remote` flag is set for all SSH sessions (wormholed or legacy), so Mode 2 applies to all remote sessions.
+2 -2
View File
@@ -76,9 +76,9 @@ Follows the existing `OpenAutoReloadModal` pattern (`view.rs:18411`, `workspace/
## 5. Remote Session Detection
`CLIAgentSession` has an `is_remote: bool` field set at session creation from `TerminalView::active_session_is_local()`. This uses `SessionType::WarpifiedRemote` and `IsLegacySSHSession` — the same logic as the SSH host chip (`context_chips/builtins.rs:76`).
`CLIAgentSession` has an `is_remote: bool` field set at session creation from `TerminalView::active_session_is_local()`. This uses `SessionType::WormholedRemote` and `IsLegacySSHSession` — the same logic as the SSH host chip (`context_chips/builtins.rs:76`).
This avoids relying on `terminal_model.is_ssh_block()` (which only tracks the pre-warpification login phase) or `is_warpified_ssh()` (which misses legacy SSH). The `is_remote` flag is threaded through `set_session` and `register_listener` at all call sites in `terminal/view.rs`.
This avoids relying on `terminal_model.is_ssh_block()` (which only tracks the pre-wormholing login phase) or `is_wormholed_ssh()` (which misses legacy SSH). The `is_remote` flag is threaded through `set_session` and `register_listener` at all call sites in `terminal/view.rs`.
## 6. Two-Mode Chip
+1 -1
View File
@@ -31,7 +31,7 @@ This spec covers three pieces:
- `app/src/terminal/view.rs (11022-11199)``TerminalView::handle_session_bootstrapped()` reacts to the event
### Session and SSH types
- `app/src/terminal/model/session.rs (691-699)``SessionType::Local` / `SessionType::WarpifiedRemote`
- `app/src/terminal/model/session.rs (691-699)``SessionType::Local` / `SessionType::WormholedRemote`
- `app/src/terminal/model/session.rs (426-451)``SessionInfo` struct with `hostname`, `user`, `session_type`, `spawning_session_id`
- `app/src/terminal/model/terminal_model.rs (632-647)``SubshellInitializationInfo` with `ssh_connection_info: Option<InteractiveSshCommand>`
- `app/src/terminal/ssh/util.rs (86-89)``InteractiveSshCommand { host, port }`
+5 -5
View File
@@ -29,7 +29,7 @@ When an AI agent runs in an SSH session, the `ApplyFileDiffs` tool is disabled b
**CodeDiffView save/delete/create**: `DiffSessionType` already exists with `Local` and `Remote(HostId)` variants. `set_candidate_diffs` routes to `register_file` (local) or `register_remote_file` (remote). `FileModel` has `FileBackend::Remote` that dispatches save/delete through `RemoteServerClient`. However, `RequestFileEditsExecutor` never sets `diff_session_type` — it defaults to `Local`.
**Agent tool gating**: `get_supported_tools` excludes `ApplyFileDiffs`, `ReadFiles`, and `SearchCodebase` when `session_type` is `WarpifiedRemote`. There is no field on `SessionContext` to indicate whether a `RemoteServerClient` is available.
**Agent tool gating**: `get_supported_tools` excludes `ApplyFileDiffs`, `ReadFiles`, and `SearchCodebase` when `session_type` is `WormholedRemote`. There is no field on `SessionContext` to indicate whether a `RemoteServerClient` is available.
**Post-accept context**: After diffs are accepted, `execute` re-reads files from disk via `read_local_file_context` and sends updated content to the LLM. This would require a network round-trip for remote sessions.
@@ -146,7 +146,7 @@ Session type is modeled as two distinct enums to separate immutable bootstrap-ti
```rust
pub enum BootstrapSessionType {
Local,
WarpifiedRemote,
WormholedRemote,
}
```
@@ -155,7 +155,7 @@ pub enum BootstrapSessionType {
```rust
pub enum SessionType {
Local,
WarpifiedRemote { host_id: Option<HostId> },
WormholedRemote { host_id: Option<HostId> },
}
```
@@ -176,10 +176,10 @@ match session_context.session_type() {
api::ToolType::SearchCodebase,
]);
}
Some(SessionType::WarpifiedRemote { host_id: Some(_) }) => {
Some(SessionType::WormholedRemote { host_id: Some(_) }) => {
supported_tools.push(api::ToolType::ApplyFileDiffs);
}
Some(SessionType::WarpifiedRemote { host_id: None }) => {
Some(SessionType::WormholedRemote { host_id: None }) => {
// Feature flag off or not yet connected — no remote tools.
}
}
+6 -6
View File
@@ -2,7 +2,7 @@
## Problem
The `ReadFiles` agent tool is disabled for remote SSH sessions. `get_supported_tools` skips `ToolType::ReadFiles` when `SessionType::WarpifiedRemote`, because the underlying `read_local_file_context` reads files via `async_fs`, `FileModel::read_text_file`, and local image processing — all local-only APIs.
The `ReadFiles` agent tool is disabled for remote SSH sessions. `get_supported_tools` skips `ToolType::ReadFiles` when `SessionType::WormholedRemote`, because the underlying `read_local_file_context` reads files via `async_fs`, `FileModel::read_text_file`, and local image processing — all local-only APIs.
The remote server already runs on the host machine with full filesystem access and has access to the same dependencies (`warp_files`, `warp_util`, `mime_guess`). Rather than building a degraded client-side approximation, we push the file-reading logic to the server so the ReadFiles tool has full feature parity with local: line-range extraction, binary/image support, metadata, and size limits.
@@ -36,7 +36,7 @@ The remote server already runs on the host machine with full filesystem access a
**Current `ReadFile` proto** (`remote_server.proto`): `ReadFile { path }``ReadFileSuccess { content, exists }`. The server handler just calls `tokio::fs::read_to_string` — no metadata, no line ranges, no size limits, no binary support.
**Tool gating**: `get_supported_tools` excludes `ReadFiles` for `WarpifiedRemote` sessions. `get_supported_cli_agent_tools` also excludes it.
**Tool gating**: `get_supported_tools` excludes `ReadFiles` for `WormholedRemote` sessions. `get_supported_cli_agent_tools` also excludes it.
## Proposed Changes
@@ -135,8 +135,8 @@ Follows the same pattern as `write_file` / `delete_file` — sends request, awai
In the `execute` method, after resolving cwd/shell, check `active_session.session_type(ctx)`:
- **Local / None**: call `read_local_file_context` as today (unchanged).
- **WarpifiedRemote with host_id**: resolve `RemoteServerClient` via `RemoteServerManager::client_for_host`, call `client.read_file_context(...)`, convert `ReadFileContextResponse``ReadFileContextResult` (mapping proto `FileContextProto``FileContext`, `FailedFileRead``missing_files`).
- **WarpifiedRemote without host_id**: fall through to the local `read_local_file_context` path.
- **WormholedRemote with host_id**: resolve `RemoteServerClient` via `RemoteServerManager::client_for_host`, call `client.read_file_context(...)`, convert `ReadFileContextResponse``ReadFileContextResult` (mapping proto `FileContextProto``FileContext`, `FailedFileRead``missing_files`).
- **WormholedRemote without host_id**: fall through to the local `read_local_file_context` path.
The remote client lookup uses a unified code path with no `cfg` gating — `RemoteServerManager` and `RemoteServerClient` compile on all targets including WASM. On WASM, `client_for_host` returns `None` (since `connect_session` is a no-op), so the local path is used automatically.
@@ -146,7 +146,7 @@ The `read_remote_file` adapter currently uses the old `ReadFile`/`ReadFileSucces
### 7. Enable `ReadFiles` in `get_supported_tools` for remote sessions
In `get_supported_tools` (impl.rs:179), add `api::ToolType::ReadFiles` alongside `ApplyFileDiffs` for the `WarpifiedRemote { host_id: Some(_) }` arm.
In `get_supported_tools` (impl.rs:179), add `api::ToolType::ReadFiles` alongside `ApplyFileDiffs` for the `WormholedRemote { host_id: Some(_) }` arm.
Also in `get_supported_cli_agent_tools` (impl.rs:234), enable `ReadFiles` for remote sessions with a connected host.
@@ -161,7 +161,7 @@ sequenceDiagram
participant Shared as read_single_file_context
LLM->>Executor: ReadFiles action (file locations)
Note over Executor: session_type is WarpifiedRemote with host_id
Note over Executor: session_type is WormholedRemote with host_id
Executor->>Client: read_file_context(files, max_bytes)
Client->>Server: ReadFileContextRequest { files, max_file_bytes, max_batch_bytes }
+1 -1
View File
@@ -175,7 +175,7 @@ The `SshRemoteServer` branch is added as the **first** check in `new_command_exe
1. SshRemoteServer + IsLegacySSHSession::Yes → RemoteServerCommandExecutor [NEW]
2. SSHTmuxWrapper + tmux_control_mode → TmuxCommandExecutor
3. SessionType::Local (various) → LocalCommandExecutor / MSYS2 / WSL
4. WarpifiedRemote + legacy SSH + !InBandForSSH → RemoteCommandExecutor
4. WormholedRemote + legacy SSH + !InBandForSSH → RemoteCommandExecutor
5. default → InBandCommandExecutor / NoOp
```
+2 -2
View File
@@ -14,7 +14,7 @@ Behavior is specified in `specs/APP-3792/PRODUCT.md`. This document updates the
- `app/src/server/server_api/ai.rs` implements that trait for the client-side `ServerApi`; current master includes the codebase calls around `generate_code_embeddings`, `sync_merkle_tree`, `populate_merkle_tree_cache`, `get_relevant_fragments`, `rerank_fragments`, and `codebase_context_config`.
- `crates/ai/src/index/full_source_code_embedding/snapshot.rs` owns serialized snapshot persistence. The daemon path should reuse the format while changing the base directory.
- `app/src/ai/blocklist/action_model/execute/search_codebase.rs:28` defines `SearchCodebaseExecutor`; the current hydration path uses local file reads after `GetRelevantFilesController`.
- `app/src/ai/agent/api/impl.rs:189-194` explicitly disables `SearchCodebase` for `WarpifiedRemote { host_id: Some(_) }`.
- `app/src/ai/agent/api/impl.rs:189-194` explicitly disables `SearchCodebase` for `WormholedRemote { host_id: Some(_) }`.
- The existing local UI strings and flows live in `app/src/ai/blocklist/codebase_index_speedbump_banner.rs:20-30` and `app/src/settings_view/code_page.rs:84-98`.
### Current remote-server architecture on master
@@ -257,7 +257,7 @@ Use this model from:
Settings should distinguish local auto-indexing from remote auto-indexing. If implementation chooses to reuse one preference, the product spec must be updated before shipping; the current product expectation is independent control.
### 3.8 Remote retrieval path
When `SearchCodebaseExecutor` runs in `SessionType::WarpifiedRemote { host_id: Some(_) }`:
When `SearchCodebaseExecutor` runs in `SessionType::WormholedRemote { host_id: Some(_) }`:
1. Resolve the active remote repo path.
2. Read `RemoteCodebaseIndexModel` for `(remote_identity_key, host_id, repo_path)`.
3. If the state is not ready/stale with a root hash, return a typed `SearchCodebaseResult::Failed` reason for indexing-in-progress, failed, disabled, unavailable, or not indexed.
+5 -5
View File
@@ -1,12 +1,12 @@
# APP-4069 — SSH Initialization UX
Linear: [APP-4069 — Initialization UX](https://linear.app/warpdotdev/issue/APP-4069/initialization-ux)
## 1. Problem
When a user SSHes into a remote host, we want to introduce a choice block for users to choose between (1) installing and connecting to the remote server (2) falling back to the existing warpify behaviour.
When a user SSHes into a remote host, we want to introduce a choice block for users to choose between (1) installing and connecting to the remote server (2) falling back to the existing wormhole behaviour.
To do so, we'll need to block the current bootstrap and connect server flow. Today, the client has a race where: `PtyController` writes the legacy bootstrap script to the PTY synchronously on `InitShell`, while `TerminalView` in parallel kicks off an async background task in `RemoteServerManager` that checks for the remote-server binary, installs it if missing, and initializes the server. Because the bootstrap is written before the check completes, we cannot defer or cancel warpification based on the check result — by the time we know whether the remote server is available, the legacy warpification has already taken effect.
To do so, we'll need to block the current bootstrap and connect server flow. Today, the client has a race where: `PtyController` writes the legacy bootstrap script to the PTY synchronously on `InitShell`, while `TerminalView` in parallel kicks off an async background task in `RemoteServerManager` that checks for the remote-server binary, installs it if missing, and initializes the server. Because the bootstrap is written before the check completes, we cannot defer or cancel wormholing based on the check result — by the time we know whether the remote server is available, the legacy wormholing has already taken effect.
This spec resolves the race by deferring the bootstrap write under the control of the remote-server setup outcome, and introduces a **two-option choice block** that appears only when the binary is missing:
- **Yes, install** — flush the bootstrap, install the binary on the remote, launch + handshake the remote server. Session is fully warpified via the remote-server path.
- **No, skip** — flush the stashed bootstrap (so the shell is properly initialized) but do not call `connect_session`. The session falls back to ControlMaster warpification without engaging the remote-server path.
- **Yes, install** — flush the bootstrap, install the binary on the remote, launch + handshake the remote server. Session is fully wormholed via the remote-server path.
- **No, skip** — flush the stashed bootstrap (so the shell is properly initialized) but do not call `connect_session`. The session falls back to ControlMaster wormholing without engaging the remote-server path.
This spec covers the following two sections:
- **Part 1 — Blocking and wiring.** A new per-pane `RemoteServerController` owns the state machine that defers the bootstrap, checks the binary via `RemoteServerManager`, and flushes at the right moment.
@@ -32,7 +32,7 @@ Two subscribers react synchronously to `ModelEvent::Handler(AnsiHandlerEvent::In
- `TerminalView` (in `view.rs:1081310836`) spawns `RemoteServerManager::connect_session`, which runs check + install + launch + handshake in a single background task.
Because both subscribers fire on the same tick but the view's work is async, the bootstrap is already written by the time the check result is known. This is the core race.
`RemoteServerManager::connect_session` today is monolithic: it emits `SetupStateChanged(Checking)` → runs the check → on "not installed" emits `SetupStateChanged(Installing)` and runs the install → on success emits `SetupReady` and proceeds to launch + handshake. There is no way to observe the binary-presence result without also triggering the install.
`ModelEventDispatcher` already has a stash-and-wait gate that waits for both `Bootstrapped` (from the remote shell sourcing the bootstrap script) and `RemoteServerReady` (forwarded today from `RemoteServerManager::SetupReady`) before calling `complete_bootstrapped_session`. The gate logic itself is unchanged, but its success-signal source moves: today `SetupReady` fires after the install decision but before `start_remote_server` and `client.initialize()` have run, so it is optimistic — launch or handshake can still fail after the gate has already resolved with `ready=true` (because `Bootstrapped` typically arrives while the handshake is still in flight), at which point the session has been committed to the warpified path against a manager that has no connected client. §4.2.1 sources the gate's success signal from `SessionConnected` (emitted only after handshake succeeds at `manager.rs:535`) to fix this.
`ModelEventDispatcher` already has a stash-and-wait gate that waits for both `Bootstrapped` (from the remote shell sourcing the bootstrap script) and `RemoteServerReady` (forwarded today from `RemoteServerManager::SetupReady`) before calling `complete_bootstrapped_session`. The gate logic itself is unchanged, but its success-signal source moves: today `SetupReady` fires after the install decision but before `start_remote_server` and `client.initialize()` have run, so it is optimistic — launch or handshake can still fail after the gate has already resolved with `ready=true` (because `Bootstrapped` typically arrives while the handshake is still in flight), at which point the session has been committed to the wormholed path against a manager that has no connected client. §4.2.1 sources the gate's success signal from `SessionConnected` (emitted only after handshake succeeds at `manager.rs:535`) to fix this.
## 4. Proposed changes
### Part 1: Blocking and wiring
#### 4.1 `RemoteServerController` — per-pane orchestrator
+2 -2
View File
@@ -6,7 +6,7 @@ The `SshRemoteServer` feature flag gates a new SSH session flow where a persiste
### Current integration test infra
The existing SSH integration tests (`crates/integration/src/test/ssh.rs`) cover the legacy warpification flow:
The existing SSH integration tests (`crates/integration/src/test/ssh.rs`) cover the legacy wormholing flow:
- Connect to a GCP-hosted Ubuntu VM (`ubuntu-14-04`) via IAP tunnel with password auth
- Helper steps in `app/src/integration_testing/subshell/``setup_gcloud_sdk()`, `enter_ssh_command()`, `enter_ssh_password()`, `wait_for_password_prompt()`
- Builder pattern: `new_builder().with_step(TestStep)` with assertion callbacks
@@ -22,7 +22,7 @@ When `SshRemoteServer` is enabled for a legacy SSH session (`app/src/terminal/wr
4. Bootstrap is flushed; `RemoteServerCommandExecutor` (`app/src/terminal/model/session/command_executor/remote_server_executor.rs`) is wired as the session's `CommandExecutor`
5. On CWD change, `navigate_to_directory` fires → returns `is_git` flag + triggers `RepoMetadataSnapshot` push
Key config: `SshExtensionInstallMode::AlwaysInstall` (setting in `app/src/terminal/warpify/settings.rs:85`) bypasses the choice block UI, needed for deterministic test flow.
Key config: `SshExtensionInstallMode::AlwaysInstall` (setting in `app/src/terminal/wormhole/settings.rs:85`) bypasses the choice block UI, needed for deterministic test flow.
### Binary deployment problem
+1 -1
View File
@@ -225,7 +225,7 @@ The SCP fallback uses a separate, longer timeout:
pub const SCP_INSTALL_TIMEOUT: Duration = Duration::from_secs(120);
```
The standard `INSTALL_TIMEOUT` (60s) is sufficient for the curl/wget path because the remote host downloads directly from the CDN. The SCP path adds a local download step (~5s) plus an SCP upload that depends entirely on the SSH link bandwidth — embedded devices, VPNs, and high-latency connections can easily exceed 60s for a ~30-50 MB transfer. Each sub-step (`scp_upload`, `run_ssh_script` for extraction) uses the full `SCP_INSTALL_TIMEOUT` individually to avoid splitting a single budget across steps, which would require coordination logic for diminishing remaining time.
- If the SCP upload or extraction fails, the error surfaces the same way as a normal install failure — `BinaryInstallComplete { result: Err(_) }` — and the session falls back to ControlMaster warpification.
- If the SCP upload or extraction fails, the error surfaces the same way as a normal install failure — `BinaryInstallComplete { result: Err(_) }` — and the session falls back to ControlMaster wormholing.
## Testing and validation
@@ -126,7 +126,7 @@ let is_cli_agent_shell_mode = self.is_locked_in_shell_mode(ctx)
if (is_command_grid_active || is_cli_agent_shell_mode) && self.can_query_history(ctx) {
```
Note this should NOT be allowed if we're in a Warpified remote host, where we cannot run in-band generators.
Note this should NOT be allowed if we're in a Wormholed remote host, where we cannot run in-band generators.
### 8. Placeholder text
+1 -1
View File
@@ -607,7 +607,7 @@ global `theme()` form. Concretely:
| --- | --- | --- |
| `app/src/terminal/view/**` | per-tab | terminal grid, blocks, in-tab modals over the grid |
| `app/src/terminal/input/**` | per-tab | input bar, inline menus, slash commands inside the tab |
| `app/src/terminal/{view.rs, block_filter.rs, rich_history.rs, terminal_manager.rs, universal_developer_input.rs, ssh/**, warpify/**, shared_session/**}` | per-tab | terminal-pane content |
| `app/src/terminal/{view.rs, block_filter.rs, rich_history.rs, terminal_manager.rs, universal_developer_input.rs, ssh/**, wormhole/**, shared_session/**}` | per-tab | terminal-pane content |
| `app/src/terminal/{share_block_modal.rs, profile_model_selector.rs}` | per-tab | scoped to a specific tab's content |
| `app/src/{tab.rs, root_view.rs, voltron.rs, modal.rs, menu.rs, search_bar.rs, input_suggestions.rs}` | global | window chrome, command palette, top-level modals |
| `app/src/settings/**` | global | settings UI |
+1 -1
View File
@@ -150,7 +150,7 @@ sequenceDiagram
## 6. Risks and mitigations
**OSC vs protocol session conflict on viewer**: If a viewer somehow receives OSC `SessionStart` events (e.g. warpified SSH where plugin events leak), the local OSC path could clobber the protocol-managed session. Mitigated by an early return in `handle_cli_agent_notification` when `is_shared_session_viewer()` is true — the protocol is the sole source of truth for viewer session lifecycle.
**OSC vs protocol session conflict on viewer**: If a viewer somehow receives OSC `SessionStart` events (e.g. wormholed SSH where plugin events leak), the local OSC path could clobber the protocol-managed session. Mitigated by an early return in `handle_cli_agent_notification` when `is_shared_session_viewer()` is true — the protocol is the sole source of truth for viewer session lifecycle.
**Echo loops**: Handled by the existing `RemoteUpdateGuard` pattern — all new broadcast subscribers check `guard.should_broadcast()`, and all incoming `apply_*` calls run inside an `ActiveRemoteUpdate` scope.
+2 -2
View File
@@ -33,7 +33,7 @@ When a Warp tab is attached to a Unix-like shell on Windows — WSL, or MSYS2 /
4. Shell-specific escaping (quoting spaces, special characters) applies on top of the transformed path using the active session's shell family — identical to the non-WSL/non-MSYS2 behavior today.
5. When the active session is neither WSL nor MSYS2/Git Bash (local PowerShell, cmd, SSH into a remote host, Warpified remote, etc.), dropped paths are inserted exactly as they are today. No transformation happens.
5. When the active session is neither WSL nor MSYS2/Git Bash (local PowerShell, cmd, SSH into a remote host, Wormholed remote, etc.), dropped paths are inserted exactly as they are today. No transformation happens.
6. Image auto-attachment (dragging an image file into Agent Mode / an empty buffer, which attaches it as AI image context) continues to use the original Windows-native path for filesystem reads, regardless of WSL / MSYS2 state. Transformed paths would not be readable from the Windows host.
@@ -47,7 +47,7 @@ When a Warp tab is attached to a Unix-like shell on Windows — WSL, or MSYS2 /
10. Non-regressions:
- Dropping into any non-terminal editor (notebooks, settings, themes, etc.) is unchanged — no path transformation is applied.
- Dropping into a non-WSL, non-MSYS2 terminal session (PowerShell, cmd, SSH, remote Warpified) is unchanged.
- Dropping into a non-WSL, non-MSYS2 terminal session (PowerShell, cmd, SSH, remote Wormholed) is unchanged.
- The terminal-grid long-running-command code paths for both WSL and MSYS2 are unchanged.
- Dropping image-only content into the input in Agent Mode still attaches the images; nothing about attachment behavior changes.
- Pasting paths via clipboard is out of scope for this ticket and remains unchanged.