# Empty-Prompt Local-to-Cloud Handoff — Stage 2 Sub-Tech-Spec (warp) Sub-tech-spec for what **Stage 2** of REMOTE-1499 delivers on the warp side. The full end-to-end architecture lives in `TECH.md`; the full product behavior lives in `PRODUCT.md`. This document is scoped to the contents of `harry/empty-prompt-handoff-local`. Branch: `harry/empty-prompt-handoff-local`, stacked on Stage 1's `harry/empty-prompt-handoff-wire-contract`. Sibling specs (cross-repo): - `warp-server/specs/REMOTE-1499/STAGE-2.md` — server-side `ShouldSkipInitialTurn` derivation + `CloudModeSetupPhaseEnded` protocol-rev bump. - `oz-agent-worker/specs/REMOTE-1499/STAGE-2.md` — self-hosted worker side of the skip-initial-turn flag. - `session-sharing-protocol/specs/REMOTE-1499/STAGE-2.md` — `OrderedTerminalEventType::CloudModeSetupPhaseEnded` variant. - `session-sharing-server/specs/REMOTE-1499/STAGE-2.md` — testing-only protocol dep swap. ## Scope Stage 2 delivers the user-facing empty-prompt handoff behavior end-to-end on the warp side, shipped unconditionally (no feature flag). It is organized below by client surface: - **Source-content guardrail and three entry points** — chip / `&` / `/handoff` all dispatch the same immediate-handoff launch when the active source conversation has at least one exchange. - **Wire-level substitution and telemetry** — `build_handoff_spawn_request` substitutes `"Continue"` against active sources, `"Apply the workspace changes from my previous session."` against idle sources with a non-empty snapshot token, and the concatenated `"Continue. Apply the workspace changes from my previous session."` when both are present; display is rendered verbatim from the wire prompt with no separate indicator enum. Telemetry tracks the intended substitution path. - **Skip-initial-turn signal** — the `AgentDriver` reads the `--skip-initial-turn` CLI flag rather than destructuring a stored client-side bool. - **Setup-phase teardown marker** — `AgentDriver::execute_run` emits `CloudModeSetupPhaseEnded` on every cloud agent run; the viewer event loop tears down the Cloud Mode Setup V2 UI on receipt. Cross-repo coordination is summarized at the end of this doc. ## Client entry points All three entry points converge in `Workspace::start_local_to_cloud_handoff_from_source` (`app/src/workspace/view.rs:13682`), which synthesizes an empty `PendingCloudLaunch` for `launch: None` dispatches from `HandoffEntryPoint::FooterChip | Ampersand | SlashCommand` (see `app/src/workspace/view.rs:13746-13767`) and collects attachments from the source input so the three entry points stay symmetric. There is no separate handoff compose pane for any of the three entry points when the source has content — all three result in the same immediate-handoff dispatch. - `app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs:2538-2548` — the `HandoffChipClicked` action emits the `AgentInputFooterEvent::HandoffChipClicked` event unconditionally (under the `OzHandoff && HandoffLocalCloud` flag gate plus the `local_fs`/non-wasm cfg). The terminal `Input` subscriber at `app/src/terminal/input.rs:2547-2572` then decides: dispatch `WorkspaceAction::OpenLocalToCloudHandoffPane { launch: None, environment_id: None, entry_point: HandoffEntryPoint::FooterChip }` when the input buffer is empty AND the active source conversation is non-empty; otherwise call `activate_cloud_handoff_compose` so any in-flight prompt is preserved. - `app/src/terminal/input.rs:4037-4102` — `maybe_launch_cloud_handoff_request` dispatches `OpenLocalToCloudHandoffPane { launch: None, ... }` on an empty buffer when the active source conversation is non-empty. Empty buffer without source content is a no-op so the compose draft is preserved. Entry point: `HandoffEntryPoint::Ampersand`. - `app/src/terminal/input/slash_commands/mod.rs:900-950` — `/handoff` with no argument dispatches the same `OpenLocalToCloudHandoffPane { launch: None, ... }` as the chip when the active source conversation is non-empty. When it is empty, the command surfaces a `"Nothing to hand off — start a conversation first."` toast (no compose-mode fallback). Entry point: `HandoffEntryPoint::SlashCommand`. ## Wire-level substitution (display = wire) `app/src/terminal/view/ambient_agent/model.rs:650-701` `build_handoff_spawn_request(prompt, attachments, forked_conversation_id, initial_snapshot_token, should_inject_continue, ctx) -> SpawnAgentRequest`: - Submitted prompt empty + `should_inject_continue` true + non-empty `InitialSnapshotToken`: substitute `prompt: Some("Continue. Apply the workspace changes from my previous session.")` on the wire (the snapshot token still rides alongside). - Submitted prompt empty + `should_inject_continue` true + no snapshot content: substitute `prompt: Some("Continue")`. - Submitted prompt empty + `should_inject_continue` false + non-empty `InitialSnapshotToken`: substitute `prompt: Some("Apply the workspace changes from my previous session.")` (the snapshot token still rides alongside). - Submitted prompt empty + `should_inject_continue` false + no snapshot content: send `prompt: None`. The worker derives `--skip-initial-turn` from the execution input. - Non-empty submitted prompt: pass it through unchanged. The bool is captured once at handoff initiation as a local `source_conversation_active` in `Workspace::start_local_to_cloud_handoff_from_source` (`app/src/workspace/view.rs:13775-13777`) from the filtered (non-empty) source conversation's status (`is_in_progress() || is_blocked()`) and stamped onto `PendingHandoff.should_inject_continue` when the pane bootstraps (`app/src/workspace/view.rs:14058`; fresh-launch path stamps `false` at `:13593`). The same `source_conversation_active` value drives the telemetry `injection_path` so the wire and analytics cannot drift across the in-progress cancellation that follows. Both `queue_handoff_auto_submit` and `submit_handoff` read `handoff.should_inject_continue` off the pending handoff and pass it as an explicit parameter to `build_handoff_spawn_request` rather than have the builder reach back into `self.pending_handoff` mid-build. `initial_snapshot_token` is `None` when called from `queue_handoff_auto_submit` and `Some(_)` when called from `submit_handoff` after the upload has settled. Callers MUST normalize an empty `String` prompt to `None` before calling the builder; the builder treats `None` as the empty-prompt signal. ## Queued-prompt indicator (no enum) `app/src/terminal/view/ambient_agent/view_impl.rs:153-170` (in `AmbientAgentViewModelEvent::DispatchedAgent`) inserts the queued-prompt block using `display_user_query_with_mode(request.mode, prompt)` against `request.prompt.as_deref()`. The whole block is gated by `FeatureFlag::CloudModeSetupV2.is_enabled()`, and the `if !prompt.is_empty()` guard at `:168` suppresses insertion when the wire prompt is `None` (the `unwrap_or_default()` on the `Option` collapses the missing-prompt case to an empty string). There is no `EmptyPromptHandoffIndicator` enum, no decoupled label rendering, and no `submitted_with_empty_prompt` bookkeeping on `PendingHandoff` — display tracks the wire one-to-one. ## Telemetry `app/src/ai/ambient_agents/telemetry.rs`: - `CloudAgentTelemetryEvent::HandoffInitiated` carries `entry_point`, `forked_existing_conversation`, `empty_prompt: bool`, and `injection_path: HandoffInjectionPath { None | Continue | SnapshotRehydration }`. - `CloudAgentTelemetryEvent::HandoffSnapshotPrepared { derived_workspace_had_content: bool }` is emitted by the model from `set_pending_handoff_workspace` after `derive_touched_workspace` settles. The field name makes clear the event reports what the snapshot pipeline produced — the upload itself may still fail downstream, so a true value does not imply the wire prompt fired the rehydration substitution. The `injection_path` variant mirrors the wire substitution decision: non-empty prompt → `None`; empty + active source → `Continue`; empty + idle source → `SnapshotRehydration`. Computed at handoff initiation in `Workspace::start_local_to_cloud_handoff_from_source` (`app/src/workspace/view.rs:13778-13785`) from the same `source_conversation_active` bool that drives the wire substitution. ## Skip-initial-turn signal The decision "should the cloud agent skip its initial LLM turn?" is computed fresh per execution on the server (`common.ShouldSkipInitialTurn(task, execution)` in warp-server) and reaches the sandboxed CLI as the `--skip-initial-turn` flag. With the client always substituting a non-empty prompt when a snapshot is present, the server-side helper simplifies to `execution.Input.Prompt.is_empty()`. The flag is the entire worker→driver contract; the wire shape between client and server is silent on it. - `app/src/server/server_api/ai.rs` — `SpawnAgentRequest` carries no `skip_initial_turn` field. The client never derives or transmits this signal. - `app/src/terminal/view/ambient_agent/model.rs:650-701` (`build_handoff_spawn_request`) and `:1175-1203` (`spawn_agent`) decide only the wire-level prompt; neither emits a `skip_initial_turn` value. - `app/src/ai/agent_sdk/driver.rs:387-398` — the `AgentRunPrompt::ServerSide` variant carries only `skill: Option` and `attachments_dir: Option`. The skip-initial-turn signal is intentionally not part of the prompt variant because the variant must round-trip through `prepare_harness` (which is harness-agnostic) without ferrying a flag that's meaningful only on the Oz harness. - `app/src/ai/agent_sdk/driver.rs:261, 337` — `AgentDriverOptions` and `AgentDriver` each carry a `skip_initial_turn: bool` field. The value is sourced from `RunAgentArgs::skip_initial_turn` (which the clap parser populates from `--skip-initial-turn`) by the `build_driver_options_and_task` closure in `mod.rs:875`, then threaded into `AgentDriver::new`. The `new_for_test` constructor initializes the field to `false`. - `app/src/ai/agent_sdk/driver.rs:2382-2405` — the gate in `execute_run` nests the skip branch inside the `ServerSide` match arm: ```rust path=null start=null if matches!(&task_prompt, AgentRunPrompt::ServerSide { .. }) { self.terminal_driver.update(ctx, |td, ctx| { td.with_terminal_view(ctx, |terminal, ctx| { if FeatureFlag::AgentView.is_enabled() { terminal.enter_agent_view( None, restored_conversation_id, AgentViewEntryOrigin::Cli, ctx, ); } terminal .model .lock() .send_cloud_mode_setup_phase_ended_for_shared_session(); }) }); if self.skip_initial_turn { run_exit.complete_with_optional_idle( self.idle_on_complete, SDKConversationOutputStatus::Success, ); } } ``` The outer `matches!` confines the skip-initial-turn short-circuit to the Oz harness path: third-party harnesses always resolve the server-side prompt through `prepare_harness`, so they go down the `AgentRunPrompt::Local` arm at `:2710-2750` (which `prepare_harness` constructs from the resolved prompt text) and never observe `skip_initial_turn`. The non-skip `ServerSide` arm continues past this block into the history-model subscription and the `if !self.skip_initial_turn` dispatch at `:2710` that fires `AIAgentInput::StartFromAmbientRunPrompt`. - `crates/warp_cli/src/agent.rs:368-380` — `--skip-initial-turn` is a hidden boolean flag on `oz agent run` with `requires_all = ["task_id", "idle_on_complete"]`. The `idle_on_complete` requirement pins the worker→driver invariant at the CLI layer: without an idle window the driver would exit immediately on `Success` before any follow-up could arrive. CLI parser tests at `crates/warp_cli/src/lib_tests.rs:232-296` pin parsing and both rejection cases. ### Considered alternatives - **Let the server decide unilaterally on the empty-prompt case — i.e. have the AgentDriver always dispatch `StartFromAmbientRunPrompt` and let the multi-agent runtime synthesize a terminal stream event (or send a no-op `AddMessagesToTask`) when it sees an empty resolved prompt, so the `--skip-initial-turn` CLI flag wouldn't exist at all.** Rejected because the driver still has to make the skip vs. dispatch fork *before* it knows what the runtime will do, for two reasons rooted in the session-sharing flow: - **Prompt resolution is born inside the stream the dispatch triggers.** The driver only ships `ambient_run_id` on `StartFromAmbientRunPrompt`; the resolved user-visible prompt is constructed by `EffectivePromptForRunInput` *inside* the multi-agent runtime in response to that dispatch, then streamed back to both the spawner *and* every session-sharing viewer as `AddMessagesToTask(UserQuery)` actions. There is no separate channel that carries the prompt text to the viewer's transcript. So "have the server decide" really means "dispatch normally, then have the server stream back an empty UserQuery that no one renders." That still warms up the runtime, opens the response stream, charges the apply-client-actions path, and immediately tears it back down — strictly more work than not dispatching in the first place. - **The viewer's `is_executing_oz_environment_startup_commands` flag needs an explicit "setup done" signal it can't get from the response stream.** Pre-feature the flag was cleared as a side effect of `AppendedExchange`, which materializes when the runtime's first `UserQuery` reaches `apply_client_actions`. On the skip path there *is* no first `UserQuery`, so even if the server streamed back a no-op the viewer's input box would never re-appear. That's exactly why the driver also emits `CloudModeSetupPhaseEnded` on the skip branch (§4). Coupling the two signals — "don't dispatch" + "explicitly mark setup done" — requires the driver to *know in advance* that no first turn is coming, which is precisely what `--skip-initial-turn` tells it. - **Alternative driver-side framing: have the driver `await` an input signal and dispatch only when it knows there's content.** Same problem in reverse: the input signal lives inside the response stream the dispatch would have triggered, so a driver that waits-then-dispatches deadlocks against itself. The driver has to commit to one of two control-flow paths at the top of `execute_run`, and `--skip-initial-turn` is the signal that picks the path. - **Two separate empty-prompt predicates — one for the worker's `--skip-initial-turn` decision in `task_utils.go`, one for the runtime's "run has no prompt" rejection inlined in `buildMessagesFromAmbientRunInput`.** This was the pre-refactor state. Rejected because the two predicates can silently diverge: the original `ShouldSkipInitialTurn` returned `true` on any empty effective prompt, while the runtime required `AgentConversationID != ""` for the same case, so the worker would happily skip a run the runtime would have rejected as invalid — a latent bug masked only by `enqueueAgentRun`'s upstream rejection. Adding a new server-known content source (e.g. a skill-derived preamble) would have required editing both sites in lockstep. The shared `common.ShouldSkipFirstTurn(task, userVisiblePrompt) (bool, error)` predicate is the single source of truth; the worker's `ShouldSkipInitialTurn` wrapper resolves the prompt via `EffectivePromptForRunInput` then delegates, and the runtime calls the same predicate against the already-resolved user-visible prompt. - **A three-variant enum return (e.g. `AllowedEmpty | HasContent | ErrorEmpty`) on `ShouldSkipFirstTurn`.** Considered for explicitness — the predicate has three meaningful outcomes — but rejected in favor of Go's `(value, error)` convention: `(true, nil)` is allowed-empty (skip), `(false, nil)` is has-content (don't skip), `(false, err)` carries the runtime's `"run has no prompt"` error directly so the runtime can return it without re-wrapping. The enum framing introduced switch-on-variant ceremony at every call site without expressing anything the `(bool, error)` shape doesn't already convey. ## Setup-phase teardown marker Every cloud agent run signals "environment setup phase complete" via the `OrderedTerminalEventType::CloudModeSetupPhaseEnded` shared-session-protocol marker. The sharer emits the marker once setup commands have finished; the viewer's event loop receives it and tears down the Cloud Mode Setup V2 UI. The marker is path-agnostic — it fires on both the skip-initial-turn path (no first LLM turn) and the normal `ServerSide` path (a first LLM turn follows). This makes the setup-phase teardown independent of whether a first `AppendedExchange` event will ever fire. ### Why a dedicated marker is needed The viewer's cloud-mode `TerminalModel` is constructed with `BlockList::is_executing_oz_environment_startup_commands = true` (set in cloud-mode terminal construction; see the explanatory comment at `app/src/pane_group/mod.rs:5843-5851` where the restored-pane path clears it before replay). That flag gates the pre-first-exchange UI state: `is_cloud_agent_pre_first_exchange` (`app/src/terminal/view/ambient_agent/mod.rs:144-196`) reads it to hide the input box and render the loading footer in its place, and `maybe_insert_setup_command_blocks` (`app/src/terminal/view/ambient_agent/view_impl.rs:420-518`) reads it to wrap incoming blocks under the "Running setup commands…" chip. Pre-feature the flag was implicitly cleared by the `AppendedExchange` handler in `app/src/terminal/view.rs` — i.e. the act of the first LLM turn starting was treated as proof setup must be done. The skip-initial-turn path never fires `AppendedExchange`, so without an explicit signal the flag stays `true` forever, the input stays hidden behind the loading footer, and the user cannot type a follow-up at all. `CloudModeSetupPhaseEnded` is the explicit "setup is done" signal that the viewer needs to flip the flag and reveal the input. ### Testing-only Cargo.toml swap `Cargo.toml:251-255` — the `session-sharing-protocol` dep is set to `path = "../session-sharing-protocol"` while the protocol PR is in flight (the `git = ..., rev = ...` form is kept commented just above so the swap-back is mechanical). Reverted to `git = ..., rev = ` after the protocol PR merges; the locally-running session-sharing-server must pick up the same `rev` bump before warp lands, or the relay will type-decode `OrderedTerminalEventType` against an older protocol rev that lacks the new variant and silently drop the marker. Tracked in PRODUCT.md "Deferred follow-ups". ### `TerminalModel` helper `app/src/terminal/model/terminal_model.rs:1419` — `send_cloud_mode_setup_phase_ended_for_shared_session` is modeled on the adjacent `send_agent_conversation_replay_started_for_shared_session`. The helper is a no-op for non-sharer terminals; sharer terminals emit a typed `OrderedTerminalEventType::CloudModeSetupPhaseEnded` event through the existing shared-session event channel. ### `AgentDriver::execute_run` structure `app/src/ai/agent_sdk/driver.rs:2363-2754` — `execute_run` builds `IdleTimeoutSender` first, then runs a single unified `ServerSide`-only block (`:2382-2405`) that enters the agent view, emits the `CloudModeSetupPhaseEnded` marker via `send_cloud_mode_setup_phase_ended_for_shared_session`, and — only when `skip_initial_turn` is set — calls `complete_with_optional_idle` to schedule the deferred `Success`. Scheduling the timer before the history subscription set up at `:2407-2657` means a later `AppendedExchange` from a session-sharing-protocol follow-up correctly invalidates the timer via `IdleTimeoutSender`'s internal generation counter (`run_exit.cancel_idle_timeout()` in the `AppendedExchange` handler at `:2494`). After the subscription is installed, `:2710-2750` dispatches the initial `StartFromAmbientRunPrompt` only when `!self.skip_initial_turn`, so the skip path falls through to `rx` without ever firing a first turn. ### Marker emission is unified across skip and non-skip `app/src/ai/agent_sdk/driver.rs:2382-2405` is the only call site for `send_cloud_mode_setup_phase_ended_for_shared_session()` in the driver. The single emission inside the `if matches!(&task_prompt, AgentRunPrompt::ServerSide { .. })` block fires before the `if self.skip_initial_turn` branch, so the skip and non-skip `ServerSide` paths share one emission point. The `AgentRunPrompt::Local` arm does not call the helper — local runs do not have a setup phase, and even though the helper is internally guarded by `is_sharer()`, keeping the call site narrow makes the emission boundary obvious. ### `IdleTimeoutSender::complete_with_optional_idle` `app/src/ai/agent_sdk/driver.rs:206-212` — `complete_with_optional_idle(idle_on_complete, value)` defers via `end_run_after` when `idle_on_complete` is `Some(d)` and falls back to `end_run_now` when `None`. The `UpdatedConversationStatus` and harness-exit branches in `execute_run` use the same helper so all completion paths honor the optional idle window uniformly. ### Viewer event_loop.rs arm `app/src/terminal/shared_session/viewer/event_loop.rs:348-357` — the `OrderedTerminalEventType::CloudModeSetupPhaseEnded` arm upgrades the terminal view and calls `view.tear_down_cloud_mode_setup_phase(ctx)`. That helper lives at `app/src/terminal/view.rs:7158-7168` and owns both pieces of state: it flips `BlockList::set_is_executing_oz_environment_startup_commands(false)` and, if an `ambient_agent_view_model` exists, calls `AmbientAgentViewModel::tear_down_active_setup_command_group` (`app/src/terminal/view/ambient_agent/model.rs:382-386`), which runs `finish_setup_command_group` + `set_setup_command_group_visibility(false)`. Both inner calls no-op when there is no active or expanded group, so the wrapper is idempotent. The arm is path-agnostic — it handles both the skip-initial-turn path and the normal cloud agent path. ### Legacy fallback teardowns Two `BlocklistAIHistoryEvent::AppendedExchange`-driven teardowns at `app/src/terminal/view.rs:5472-5483` (which clears `is_executing_oz_environment_startup_commands` only) and `app/src/terminal/view/ambient_agent/block/setup_command_text.rs:118-130` (which calls `finish_setup_command_group` + `set_setup_command_group_visibility(false)` then unsubscribes) remain in place as a compatibility fallback for viewers that connect to sharers running pre-feature builds. Together they cover the same state the new `tear_down_cloud_mode_setup_phase` wrapper does. Both teardowns are idempotent with the `CloudModeSetupPhaseEnded` arm, so a new sharer + new viewer pair triggering both is harmless. Removal is tracked in PRODUCT.md "Deferred follow-ups". ### Considered alternatives - **Reusing `AppendedExchange` as the teardown signal everywhere.** Rejected: the skip-initial-turn path never fires `AppendedExchange`, so the viewer's `is_executing_oz_environment_startup_commands` flag stays `true`, `is_cloud_agent_pre_first_exchange` keeps reporting the pane is in setup, the input box stays hidden behind the loading footer, and the user cannot type a follow-up at all. A dedicated marker decouples setup-phase teardown from first-LLM-turn semantics. - **Letting the AgentDriver send `Success` directly to the oneshot on the skip path (no `IdleTimeoutSender` involvement).** Rejected: the driver tears down ~80ms after sending `Success`, which is too fast for a follow-up session-sharing-protocol exchange to arrive. Routing the skip path through `IdleTimeoutSender::complete_with_optional_idle` honors `idle_on_complete` uniformly across completion paths. ## Tests - Behavioral tests in `app/src/terminal/view/ambient_agent/model_tests.rs` cover the three substitution outcomes in `build_handoff_spawn_request`: - In-progress source + empty prompt → wire prompt is `"Continue"` (queue path). - Idle source + empty prompt → wire prompt is `None` (queue path; the snapshot token is not known at this stage). - Idle source + empty prompt + uploaded snapshot token → wire prompt is `"Apply the workspace changes from my previous session."` (submit_handoff path; the snapshot token rides alongside). - Idle source + empty prompt + skipped snapshot → wire prompt is `None` (submit_handoff path). - CLI parser tests at `crates/warp_cli/src/lib_tests.rs:232-296` pin the `--skip-initial-turn` flag's parsing and both `requires_all` rejection cases (`agent_run_accepts_skip_initial_turn_with_task_id_and_idle_on_complete`, `agent_run_rejects_skip_initial_turn_without_idle_on_complete`, `agent_run_rejects_skip_initial_turn_without_task_id`). - Viewer-side tests in `app/src/terminal/shared_session/viewer/event_loop_tests.rs` cover the `CloudModeSetupPhaseEnded` arm and its idempotency. - Sandbox-side unit tests cover `TerminalModel::send_cloud_mode_setup_phase_ended_for_shared_session` (sharer-emits + non-sharer-no-op). - Direct `IdleTimeoutSender::complete_with_optional_idle` tests in `app/src/ai/agent_sdk/driver_tests.rs` cover `None` immediate completion, `Some(d)` deferred completion, and `Some(d)` + cross-path `cancel_idle_timeout()` invalidation. - The driver-side `CloudModeSetupPhaseEnded` emission on the non-skip path is exercised end-to-end by the standard cloud-mode handoff smoke test. ## Validation - `cargo fmt --all --check`. - `cargo check -p warp --tests`. Nextest and full clippy are intentionally not part of the per-PR validation for this work — the changes touch isolated client-side wiring, and the targeted `cargo check` plus the per-stage unit tests cover the relevant surfaces. ## Cross-repo coordination summary - `SpawnAgentRequest` (`POST /agent/run`): does not carry `skip_initial_turn`. The wire-shape change is local to this stage and is the only Stage-2 contract between warp and warp-server. - `TaskAssignmentMessage` (warp-server → self-hosted worker): top-level `AdditionalOzArgs []string` (JSON tag `additional_oz_args`, `omitempty`). warp-server computes the decision fresh per execution via `ShouldSkipInitialTurn` (simplified to `prompt.is_empty()`) and sends `--skip-initial-turn` in the slice only when applicable. The self-hosted worker forwards those tokens unchanged. - `--skip-initial-turn` CLI flag (worker → CLI): the sole worker→driver contract for the skip-initial-turn decision. - `OrderedTerminalEventType::CloudModeSetupPhaseEnded` (sharer → viewer via session-sharing-protocol): new variant in the protocol crate. The testing-only protocol dep swap in `Cargo.toml:251-255` keeps the locally-running relay decoding the new variant until the protocol PR merges and the relay picks up the `rev` bump.