Files
galaxy/specs/REMOTE-1454/TECH.md
T

362 lines
30 KiB
Markdown

# REMOTE-1454: Tech Spec — Cloud Mode setup UI for non-oz harnesses
## Problem
The `CloudModeSetupV2` setup UI only transitions out of the "setup commands" phase when an Oz `AppendedExchange` event fires. Non-oz harness runs (claude, gemini) never produce an Oz exchange — the sandboxed `oz agent run --harness=<name>` invokes the harness CLI (e.g. `claude --session-id … < /tmp/oz_prompt`) as a shell command in the shared session, and that command is permanently flagged as an environment startup command. See `specs/REMOTE-1454/PRODUCT.md` for the desired UX.
## Relevant code
- `crates/warp_features/src/lib.rs:757,826``FeatureFlag::AgentHarness`, `FeatureFlag::CloudModeSetupV2`.
- `app/src/terminal/view/ambient_agent/model.rs:79-151,240-250,384-418,442-488,726-727,886-899``AmbientAgentViewModel` (status, `harness` field, `spawn_agent`, `enter_viewing_existing_session`, `AmbientAgentViewModelEvent`, `DispatchedAgent` emission).
- `app/src/terminal/view/ambient_agent/view_impl.rs:91-228,230-300``handle_ambient_agent_event` (inserts `CloudModeInitialUserQuery` on `DispatchedAgent`), `maybe_insert_setup_command_blocks` (gated on `is_cloud_agent_pre_first_exchange`).
- `app/src/terminal/view/ambient_agent/mod.rs:81-114``is_cloud_agent_pre_first_exchange` helper (checks `exchange_count() == 0`).
- `app/src/terminal/view/ambient_agent/block/query.rs``CloudModeInitialUserQuery` rich content (top-of-conversation prompt block).
- `app/src/terminal/view/ambient_agent/block/setup_command_text.rs:122-157` — summary row copy keys off `is_cloud_agent_pre_first_exchange`.
- `app/src/terminal/view/ambient_agent/block/setup_command.rs:56-91` — per-command row, listens for `UpdatedSetupCommandVisibility` and `BlockCompleted`.
- `app/src/terminal/model/terminal_model.rs:1213-1241``new_for_cloud_mode_shared_session_viewer` sets `is_executing_oz_environment_startup_commands = true` when `CloudModeSetupV2` is enabled.
- `app/src/terminal/model/blocks.rs:1284-1302,2486-2489` — block-list flag and per-block `is_oz_environment_startup_command`.
- `app/src/terminal/model/block.rs:1462-1468``Block::is_oz_environment_startup_command` / setter.
- `app/src/terminal/view.rs:5042-5053` — existing `AppendedExchange` handler that flips the block-list flag off (Oz path).
- `app/src/terminal/view.rs:10413``ModelEvent::AfterBlockStarted` branch calls `maybe_insert_setup_command_blocks(block_id, ctx)`.
- `app/src/terminal/view.rs:6609-6634,6785-6793,6841-6863` — uses of `is_cloud_agent_pre_first_exchange` (tombstone, input visibility, input hide logic).
- `app/src/ai/blocklist/block/pending_user_query_block.rs``PendingUserQueryBlock` view, takes `interruptible: bool`, always renders a close button plus optional "Send now".
- `app/src/terminal/view/pending_user_query.rs``TerminalView::insert_pending_user_query_block`, `remove_pending_user_query_block`, `send_user_query_after_next_conversation_finished`. Tracked by `TerminalView::pending_user_query_view_id`.
- `app/src/terminal/view.rs:2789-2796,4160``TerminalView::pending_user_query_view_id`, `queued_prompt_callback` fields.
- `app/src/terminal/view.rs:3237-3246,4648-4650` — existing removal paths for the pending-query block on agent-view/conversation transitions.
- `app/src/ai/ambient_agents/task.rs:26-122,212-238``AgentConfigSnapshot.harness: Option<HarnessConfig>`, `AmbientAgentTask.agent_config_snapshot`.
- `app/src/terminal/cli_agent.rs:107-260``CLIAgent` enum (`Claude`, `Gemini`, …) with `command_prefix`, `CLIAgent::detect`.
- `app/src/terminal/view/use_agent_footer/mod.rs:354-387``TerminalView::detect_cli_agent_from_model` (returns the `CLIAgent` for the active long-running block using alias-aware detection).
- `app/src/terminal/shared_session/viewer/terminal_manager.rs:871-896,1186-1209``is_cloud_agent_pre_first_exchange` use in remote input / input-mode gating.
- `app/src/terminal/view/shared_session/view_impl.rs:1700-1721` — viewer-driven sizing helpers: `restore_pty_to_sharer_size`, `resize_from_viewer_report`, and the new `force_report_viewer_terminal_size` entry point used at harness start.
- `app/src/terminal/view/ambient_agent/block/setup_command.rs:176-197` — per-command row, currently renders via `with_agent_output_item_spacing`; needs a third-party-harness branch that uses `*terminal::view::PADDING_LEFT` horizontal margins.
- `app/src/terminal/view/ambient_agent/block/setup_command_text.rs:184-206` — summary row, same story as the per-command row.
- `app/src/ai/blocklist/block/view_impl.rs:1264-1312``CONTENT_HORIZONTAL_PADDING`, `CONTENT_ITEM_VERTICAL_MARGIN`, and the `WithContentItemSpacing::with_agent_output_item_spacing` helper used by the oz setup UI.
- `app/src/terminal/view.rs:746-752``PADDING_LEFT` (20px, or 16px behind `LessHorizontalTerminalPadding`) — the horizontal padding used by regular terminal command blocks.
- `app/src/pane_group/mod.rs:3763-3792,5682-5692` — CLI-agent conversation restoration (`FeatureFlag::AgentHarness` gate for replay).
- `warp_cli::agent::Harness` (`crates/warp_cli/src/agent.rs:120-131`) — `Oz`, `Claude`, `Gemini`.
## Current state
`AmbientAgentViewModel::harness` is populated on spawn for the local spawner via the harness selector. For viewers that join a shared cloud session, the field is left at its default `Oz` even for claude / gemini runs — `enter_viewing_existing_session` fetches the task but only reads `agent_config_snapshot.environment_id`. The viewer therefore reports the wrong harness until this is fixed.
When the cloud-mode terminal model is created, `is_executing_oz_environment_startup_commands` is set to `true` unconditionally under `CloudModeSetupV2`. The block-list flag:
- Marks every new block `is_oz_environment_startup_command = true` and hides it.
- Is flipped to `false` only in `ModelEvent::AfterBlockStarted`'s sibling `BlocklistAIHistoryEvent::AppendedExchange` handler (view.rs:5042-5053).
`is_cloud_agent_pre_first_exchange` is a view-level helper that returns `true` while the active cloud-agent conversation has `exchange_count() == 0`. It gates: the "Setting up environment" warping indicator, the pre-harness input-box hiding, remote-input suppression in `shared_session/viewer/terminal_manager.rs`, and `maybe_insert_setup_command_blocks`. For non-oz there are no exchanges, so this stays `true` forever.
`maybe_insert_setup_command_blocks` runs in the `AfterBlockStarted` path and, when pre-first-exchange, inserts the `CloudModeSetupTextBlock` summary row (once) plus a `CloudModeSetupCommandBlock` row for the block. It has no knowledge of whether the block is the harness CLI command.
`PendingUserQueryBlock::new` currently takes `interruptible: bool` and always renders a close button; the "Send now" button is added only when `interruptible`. `TerminalView::insert_pending_user_query_block` subscribes to `Dismissed`/`SendNow` events; `send_user_query_after_next_conversation_finished` also registers a `queued_prompt_callback` so the prompt is re-submitted when the current conversation finishes.
## Proposed changes
### 1. `PendingUserQueryBlock` — per-button bools
`app/src/ai/blocklist/block/pending_user_query_block.rs`
Replace the `interruptible: bool` constructor param with two bool fields on the block itself:
- `show_close_button: bool` — whether to render the dismiss ("X") button.
- `show_send_now_button: bool` — whether to render the "Send now" button.
Update `PendingUserQueryBlock`:
- Make `close_button: Option<ViewHandle<ActionButton>>` (was unconditional), constructed only when `show_close_button` is true.
- `send_now_button: Option<ViewHandle<ActionButton>>` stays optional, constructed only when `show_send_now_button` is true.
- Build the buttons column with 0, 1, or 2 children. If it ends up empty, skip the whole "buttons" container.
- Constructor signature becomes `PendingUserQueryBlock::new(prompt, user_display_name, profile_image_path, show_close_button, show_send_now_button, ctx)`.
This keeps the wire-up trivial for each call site (pass the two bools) and avoids introducing an enum just to express three combinations.
### 2. `TerminalView` helpers — thread the button bools and add a cloud-mode insertion path
`app/src/terminal/view/pending_user_query.rs`
- Change `insert_pending_user_query_block`'s `interruptible: bool` param to `show_close_button: bool, show_send_now_button: bool` and pass them to `PendingUserQueryBlock::new`.
- Keep subscribing to `PendingUserQueryBlockEvent::Dismissed` and `PendingUserQueryBlockEvent::SendNow`. When both bools are `false`, the block never emits these events, so the subscription is effectively inert.
- Change `send_user_query_after_next_conversation_finished`'s `interruptible: bool` param to the same two bools and pass them through. Update existing callers:
- `app/src/workspace/view.rs:11605` (`handle_forked_conversation_prompts`, `/fork-and-compact`) — `show_close_button: true, show_send_now_button: false` (was `false`).
- `app/src/workspace/view.rs:11701` (`summarize_active_ai_conversation`, `/compact-and`) — `show_close_button: true, show_send_now_button: false` (was `false`).
- `app/src/workspace/view.rs:20955` (`QueuePromptForConversation`, `/queue`) — `show_close_button: true, show_send_now_button: true` (was `true`).
- Add a new public method used by Cloud Mode:
```rust
pub(in crate::terminal::view) fn insert_cloud_mode_queued_user_query_block(
&mut self,
prompt: String,
ctx: &mut ViewContext<Self>,
) {
self.remove_pending_user_query_block(ctx);
self.insert_pending_user_query_block(
prompt,
/* show_close_button */ false,
/* show_send_now_button */ false,
ctx,
);
}
```
This does NOT register a `queued_prompt_callback` — the prompt is not re-submitted; it is already being carried by the harness inside the sandbox.
`remove_pending_user_query_block` already clears `queued_prompt_callback` on top of removing the view. It remains the single teardown entry point.
### 3. `AmbientAgentViewModel` — resolve harness for viewers, track harness-command-started, new event
`app/src/terminal/view/ambient_agent/model.rs`
Add a helper for the non-oz check:
```rust
impl AmbientAgentViewModel {
/// True when the run is configured to use a non-Oz execution harness and the
/// required feature flags are enabled.
pub fn is_third_party_harness(&self) -> bool {
FeatureFlag::AgentHarness.is_enabled() && self.harness != Harness::Oz
}
}
```
Fix the viewer-side harness resolution so `AmbientAgentViewModel::harness` reflects the run's actual harness for every client, not just the spawner. In `enter_viewing_existing_session`, extend the success branch to also resolve the harness from `task.agent_config_snapshot.harness` and call `set_harness`:
```rust
Ok(task) => {
let snapshot = task.agent_config_snapshot.as_ref();
let environment_id = snapshot
.and_then(|s| s.environment_id.as_deref())
.and_then(|id| ServerId::try_from(id).ok())
.map(SyncId::ServerId);
let harness = snapshot
.and_then(|s| s.harness.as_ref())
.map(|h| parse_harness_config_name(&h.harness_type))
.unwrap_or(Harness::Oz);
me.set_environment_id(environment_id, ctx);
me.set_harness(harness, ctx);
}
```
`parse_harness_config_name` is a small module-local helper that matches `"claude"` / `"gemini"` / `"oz"` explicitly and falls back to `Harness::Oz` (with a warn log) on unknown values. `set_harness` is a no-op when unchanged.
Add a harness-command-started tracker:
```rust
pub struct AmbientAgentViewModel {
// ... existing fields ...
harness_command_started: bool,
}
impl AmbientAgentViewModel {
pub fn harness_command_started(&self) -> bool {
self.harness_command_started
}
pub fn mark_harness_command_started(&mut self, ctx: &mut ModelContext<Self>) {
if self.harness_command_started {
return;
}
self.harness_command_started = true;
ctx.emit(AmbientAgentViewModelEvent::HarnessCommandStarted);
}
}
```
Initialize it to `false` in `new` and `reset_status`.
Add the event variant:
```rust
pub enum AmbientAgentViewModelEvent {
// ... existing ...
HarnessCommandStarted,
}
```
### 4. `is_cloud_agent_pre_first_exchange` — also honor harness-started
`app/src/terminal/view/ambient_agent/mod.rs`
Extend the helper so both Oz's "first exchange" and non-oz's "harness command started" transitions end the pre-first-exchange phase. At the site that currently returns based on `exchange_count() == 0`:
```rust
if ambient_agent_view_model.as_ref(app).harness_command_started() {
return false;
}
BlocklistAIHistoryModel::as_ref(app)
.conversation(&conversation_id)
.is_some_and(|conversation| conversation.exchange_count() == 0)
```
This keeps every existing consumer (`status_bar`, input-box visibility, remote-input suppression, `setup_command_text`) correctly reading `false` after the harness block is detected.
### 5. Detect the harness block and emit `HarnessCommandStarted`
`app/src/terminal/view/ambient_agent/view_impl.rs` — `maybe_insert_setup_command_blocks`
This function is already the single hook called from `ModelEvent::AfterBlockStarted` for cloud-mode setup bookkeeping, so it's the right place to detect the harness block.
Before doing any setup-row insertion, short-circuit if the active block's CLI-agent matches the run's harness:
```rust
pub(in crate::terminal::view) fn maybe_insert_setup_command_blocks(
&mut self,
block_id: &BlockId,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::CloudModeSetupV2.is_enabled() {
return;
}
// Only act while we're still in the pre-first-exchange phase.
if !super::is_cloud_agent_pre_first_exchange(
&self.ambient_agent_view_model,
&self.agent_view_controller,
ctx,
) {
return;
}
// For non-oz harness runs, transition out of the setup phase when the
// harness CLI starts. The block becomes a normal CLI-agent session rather
// than a setup command.
if self.ambient_agent_view_model.as_ref(ctx).is_third_party_harness()
&& self.active_block_matches_run_harness(ctx)
{
self.ambient_agent_view_model.update(ctx, |m, ctx| {
m.mark_harness_command_started(ctx);
});
return;
}
// ... existing setup-row insertion logic, now guarded by the
// is_cloud_agent_pre_first_exchange check above ...
}
```
`active_block_matches_run_harness` is a small new helper on `TerminalView` that runs `CLIAgent::detect` directly (no alias or shell-escape handling) and matches against the run's harness:
```rust
fn active_block_matches_run_harness(&self, ctx: &AppContext) -> bool {
let command = self
.model
.lock()
.block_list()
.active_block()
.command_with_secrets_obfuscated(false);
let Some(cli_agent) = CLIAgent::detect(&command, None, None, ctx) else {
return false;
};
match self.ambient_agent_view_model.as_ref(ctx).selected_harness() {
Harness::Oz => false,
Harness::Claude => matches!(cli_agent, CLIAgent::Claude),
Harness::Gemini => matches!(cli_agent, CLIAgent::Gemini),
}
}
```
Cloud-mode harness runs always invoke the canonical `claude` / `gemini` CLI prefix from the harness sidecar (hardcoded in `app/src/ai/agent_sdk/driver/harness/claude_code.rs:84-95` and the matching `gemini.rs`). Passing `None` for both the escape char and aliases is sufficient; if a future wrapper (e.g. `bash -l -c 'claude …'`) breaks canonical detection, the fallback trigger under *Risks and mitigations* applies. We deliberately avoid `detect_cli_agent_from_model` because it gates on `is_active_and_long_running`, which has not yet elapsed at `AfterBlockStarted` time.
### 6. Handle `HarnessCommandStarted` in the view
`app/src/terminal/view/ambient_agent/view_impl.rs` — `handle_ambient_agent_event`
Add a new arm that mirrors how `AppendedExchange` already tears down the setup phase for Oz, removes the cloud-mode queued prompt block, and forces a fresh viewer-size report to the sharer so the harness CLI lays out at our current dimensions:
```rust
AmbientAgentViewModelEvent::HarnessCommandStarted => {
// Stop classifying new blocks as environment setup commands and let the
// existing CLI-agent rendering take over for the harness block.
let mut model = self.model.lock();
if model.block_list().is_executing_oz_environment_startup_commands() {
model
.block_list_mut()
.set_is_executing_oz_environment_startup_commands(false);
}
drop(model);
self.remove_pending_user_query_block(ctx);
// Collapse the setup-commands summary now that we're past setup (matches
// the existing behavior when the first AI exchange appears).
self.ambient_agent_view_model.update(ctx, |model, ctx| {
model.set_setup_command_visibility(false, ctx);
});
// Force a fresh viewer size report to the sharer so the harness CLI
// (e.g. the claude TUI) starts at the viewer's actual dimensions
// instead of whatever the sandbox PTY was sized to during setup.
self.force_report_viewer_terminal_size(ctx);
ctx.notify();
}
```
The Oz `AppendedExchange` handler in `view.rs:5042-5053` stays as-is.
### 6a. `force_report_viewer_terminal_size` helper
`app/src/terminal/view/shared_session/view_impl.rs`
Viewer-driven resize normally dedups on the last reported natural size (`SharedSessionViewer::last_reported_natural_size`) so a noop refresh doesn't spam the sharer. At harness start we specifically want to break that dedup: the sandbox PTY was resized during setup (to whatever size the pre-harness commands dictated), and the harness TUI will fail to lay out correctly if we don't re-issue our report.
Add:
```rust
/// Forces a fresh viewer-size report to the sharer by clearing the dedup cache and
/// refreshing size. No-op when not an active viewer or when viewer-driven sizing is
/// not eligible.
pub(in crate::terminal::view) fn force_report_viewer_terminal_size(
&mut self,
ctx: &mut ViewContext<Self>,
) {
if let Some(viewer) = self.shared_session_viewer_mut() {
viewer.last_reported_natural_size = None;
}
self.refresh_size(ctx);
}
```
`refresh_size` already funnels through the standard viewer-driven-sizing eligibility checks (`is_viewer_driven_sizing_eligible`), so this is a no-op for sharers, for multi-viewer shared sessions, and for non-cloud-agent shared sessions. Cloud-agent shared sessions bypass the same-user check (`is_shared_session_for_ambient_agent`), so the viewer will always resize the sandbox PTY on harness start.
### 7. Route the initial prompt to the queued-prompt UI for non-oz runs
`app/src/terminal/view/ambient_agent/view_impl.rs` — `DispatchedAgent` handler (currently view_impl.rs:106-135)
Branch on the run's harness:
```rust
AmbientAgentViewModelEvent::DispatchedAgent => {
self.update_pane_configuration(ctx);
if FeatureFlag::CloudModeSetupV2.is_enabled() {
let model = self.ambient_agent_view_model.as_ref(ctx);
if model.is_third_party_harness() {
let prompt = model
.request()
.map(|request| request.prompt.clone())
.unwrap_or_default();
if !prompt.is_empty() {
self.insert_cloud_mode_queued_user_query_block(prompt, ctx);
}
// Do NOT set has_inserted_cloud_mode_user_query_block for third-party
// harnesses — the queued block path handles its own lifecycle and the
// flag only gates Oz-specific first-exchange dedup logic.
} else {
// ... existing Oz path: insert CloudModeInitialUserQuery, set
// has_inserted_cloud_mode_user_query_block(true) ...
}
} else {
// ... existing tip-reset branch, unchanged ...
}
ctx.notify();
}
```
For viewers / historical replay, `request()` is `None` on the viewer's model (the viewer doesn't spawn), so the queued block is not inserted — matching the product-spec rule that late joiners don't see it.
### 8. Remove pending-query block on pre-harness terminal states
`app/src/terminal/view/ambient_agent/view_impl.rs` — extend existing `Failed`, `Cancelled`, and `NeedsGithubAuth` arms:
Each arm gets the same additional line (idempotent and cheap; no-op if no block present):
```rust
self.remove_pending_user_query_block(ctx);
```
This covers the product-spec requirement that failing/cancelling/auth-required runs before the harness block starts remove the queued indicator and fall back to the existing error/cancel/auth UI. The fallback UI is already rendered by the existing status-bar and loading-screen code paths under `CloudModeSetupV2`.
### 9. Cloud-mode submission-block guard remains harness-agnostic
`app/src/terminal/input.rs:5725-5734` — `should_block_cloud_mode_setup_submission` currently blocks local input submission during the pre-harness/pre-first-exchange window. Because we updated `is_cloud_agent_pre_first_exchange` to also return false after `HarnessCommandStarted`, this guard already stops firing once the harness block starts, allowing input to flow to the harness TUI normally. No change needed.
### 10. Setup UI horizontal padding for non-oz harnesses
`app/src/terminal/view/ambient_agent/block.rs`, `block/setup_command.rs`, `block/setup_command_text.rs`
The oz setup UI wraps its per-command row and summary row in `WithContentItemSpacing::with_agent_output_item_spacing`, which applies a left margin of `CONTENT_HORIZONTAL_PADDING + icon_size + 16.` so the setup rows line up with other oz agent-output items (reasoning, actions, etc.). For non-oz harness runs, the harness CLI block that takes over after setup is a regular terminal command block and uses `*terminal::view::PADDING_LEFT` (20px, or 16px with `LessHorizontalTerminalPadding`) as its horizontal padding. Using the oz indent for non-oz setup rows causes a visible horizontal jump at harness start.
A shared helper `cloud_mode_setup_row_spacing` in `block.rs` picks the right spacing per harness:
```rust
pub(super) fn cloud_mode_setup_row_spacing(
element: Box<dyn Element>,
ambient_agent_view_model: &ModelHandle<AmbientAgentViewModel>,
app: &AppContext,
) -> Container {
if ambient_agent_view_model
.as_ref(app)
.is_third_party_harness()
{
Container::new(element)
.with_margin_left(*PADDING_LEFT)
.with_margin_right(*PADDING_LEFT)
.with_margin_bottom(CONTENT_ITEM_VERTICAL_MARGIN)
} else {
element.with_agent_output_item_spacing(app)
}
}
```
`CloudModeSetupCommandBlock` calls this in its collapsed-header branch (the expanded detail view keeps its own corner-radius / container treatment). `CloudModeSetupTextBlock` calls this at the end of `render` instead of the standalone `.with_agent_output_item_spacing(app).finish()`.
### 11. Feature flag gating
- All new code paths that check `is_third_party_harness` also implicitly depend on `FeatureFlag::AgentHarness` (the helper checks it).
- The outer `if FeatureFlag::CloudModeSetupV2.is_enabled()` guard in `DispatchedAgent` and `maybe_insert_setup_command_blocks` continues to gate the whole setup-v2 UI; when the flag is off, the legacy loading screen / full-screen overlay path is unchanged.
- When `AgentHarness` is disabled, `is_third_party_harness` always returns `false`, the Oz paths are taken for everything, and the spec's new behavior never activates. Replay of CLI-agent conversations is already guarded by the same flag in `pane_group/mod.rs:3763,5682` and `terminal/view/load_ai_conversation.rs:268`.
## End-to-end flow (non-oz Cloud Mode run)
1. User selects claude/gemini in the harness selector and submits a prompt.
2. `AmbientAgentViewModel::spawn_agent` builds a `SpawnAgentRequest` with `HarnessConfig::from_harness_type`; `self.harness` is non-oz. Stream emits `DispatchedAgent`.
3. `handle_ambient_agent_event` sees `is_third_party_harness()` and calls `insert_cloud_mode_queued_user_query_block(prompt)`. `PendingUserQueryBlock` is rendered with `show_close_button: false` and `show_send_now_button: false`.
4. Spawn stream progresses to `SessionReady`; the cloud-mode terminal-model viewer is built with `is_executing_oz_environment_startup_commands = true`.
5. Environment setup blocks start. `maybe_insert_setup_command_blocks` sees `is_cloud_agent_pre_first_exchange == true` and `active_block_matches_run_harness == false`, so it inserts the `CloudModeSetupTextBlock` summary row and a `CloudModeSetupCommandBlock` per block. Blocks are marked `is_oz_environment_startup_command` by the existing block-list logic.
6. Sandbox's `oz agent run` executes `claude --session-id … < /tmp/oz_prompt` (from `agent_sdk/driver/harness/claude_code.rs`). The viewer sees this as a new long-running block.
7. `maybe_insert_setup_command_blocks` detects `CLIAgent::Claude` matches `Harness::Claude`, calls `mark_harness_command_started`, and returns without inserting setup rows for this block.
8. `HarnessCommandStarted` fires → view arm flips `is_executing_oz_environment_startup_commands = false`, removes the pending-query block, collapses the setup-commands summary, and calls `force_report_viewer_terminal_size` so the sandbox PTY is resized to our current dimensions before claude's TUI lays out its first frame.
9. From now on, `is_cloud_agent_pre_first_exchange` returns false. The claude block is a normal CLI-agent session: `detect_cli_agent_from_model` + `CLIAgentSessionsModel::set_session` already run in `view.rs:10343-10378`, so the CLI-agent footer and rich-input UI activate. Setup rows rendered earlier in the run use `PADDING_LEFT` margins so the visible left edge of content does not shift when the harness block takes over.
10. On pre-harness `Failed` / `Cancelled` / `NeedsGithubAuth`: the new `remove_pending_user_query_block` calls tear down the queued indicator; the existing error/cancel/auth UI remains.
## Risks and mitigations
**Missing harness match.** If `CLIAgent::detect` fails to identify the harness (e.g. a future wrapper like `bash -c 'claude …'`), we never transition out of setup and the claude block stays flagged as a setup command. The short-circuit only covers the sandbox's canonical invocation; if detection fails, behavior regresses to the current broken state rather than getting worse. A fallback trigger (e.g. timeout-based transition) can be added as a follow-up if this becomes a problem.
**Viewer harness resolution races.** `enter_viewing_existing_session` fetches the task asynchronously. If the harness block starts before the fetch completes, `is_third_party_harness()` returns false at the time of detection and we treat the block as a setup command. In practice the task fetch is issued on join and the viewer only sees blocks after the shared session connects, which is later. If needed, we can re-evaluate the harness on task fetch completion and fire `mark_harness_command_started` retroactively.
**`is_cloud_agent_pre_first_exchange` semantics shift.** Consumers were designed around "conversation has no exchanges yet". After this change, the helper also returns false when a harness command has started. Every caller (`status_bar`, `is_input_box_visible`, `shared_session/viewer/terminal_manager`, `setup_command_text`, `maybe_insert_setup_command_blocks`) behaves correctly under the new semantics for non-oz runs and is a no-op for Oz runs (since `harness_command_started` is never set on Oz).
## Testing and validation
Unit coverage:
- Add a test for `PendingUserQueryBlock` rendering for each `(show_close_button, show_send_now_button)` combination (both-true, close-only, neither).
- Add a test for `AmbientAgentViewModel::mark_harness_command_started` idempotency and event emission, and for `is_third_party_harness` gating on `AgentHarness` + `harness` field.
- Add a test that `enter_viewing_existing_session` populates `harness` from the fetched `AmbientAgentTask.agent_config_snapshot.harness` for claude / gemini / oz.
- Extend `is_cloud_agent_pre_first_exchange` tests (or add one) to cover the harness-started early-return.
- Add a test that `force_report_viewer_terminal_size` clears `last_reported_natural_size` and calls `refresh_size`, and is a no-op for sharers / when viewer-driven sizing is ineligible.
Integration / manual validation (mirrors the product spec's validation list):
- Spawn a claude-code Cloud Mode run with multiple startup commands; confirm queued prompt block (no buttons) appears, setup-commands summary expands during setup, and on claude start the queued block is removed, summary collapses to "Ran setup commands", and the claude TUI is the active CLI-agent block. Confirm the claude TUI reports the viewer's current rows/cols (resize the pane during setup and verify the TUI lays out at the new size on harness start) and that the setup-command rows are horizontally flush with the subsequent harness terminal block (no visible left-edge jump).
- Spawn a gemini run; confirm the same behavior when `gemini` is detected.
- Spawn a claude run with no environment setup commands; confirm the queued block is removed when the harness block starts without a setup-commands summary having been inserted.
- Spawn a claude run that fails during env setup; confirm queued block is removed on `Failed` and the existing error UI renders.
- Cancel a claude run pre-harness; confirm queued block removal + cancelled UI.
- Trigger GitHub-auth-required pre-harness; confirm queued block removal + auth UI.
- Join an existing claude shared session after the harness has started; confirm no queued block, collapsed setup summary (from prior blocks), claude TUI visible.
- Replay a completed claude conversation; confirm no queued block, transcript rendered correctly via the existing CLI-agent block-snapshot path.
- Re-run all Oz validation cases from `specs/REMOTE-172/PRODUCT.md` to confirm no regression.
- Toggle `CloudModeSetupV2` off; confirm legacy cloud-mode behavior restored for all harnesses.
- Toggle `AgentHarness` off; confirm harness selector hides non-oz options and nothing in this spec activates.
## Follow-ups
- Consider telemetry for `HarnessCommandStarted` (time from `DispatchedAgent` / `SessionReady` to harness start) so we can monitor setup latency per-harness.
- If detection proves flaky in practice, add a timeout-based fallback transition (e.g. "still in setup 60s after session ready with no matching CLI agent detection → transition anyway and log a warning").
- Once `CloudModeSetupV2` ships to stable, consider unifying the "pre-first-exchange" and "pre-harness" concepts behind a single view-model state rather than the current combination of block-list flag + exchange count + harness-started bool.