Skip to main content

agentmux_srv/server/app_api/
pane.rs

1use super::*;
2use agentmux_common::AUTH_KEY_HEADER;
3
4pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
5    register_pane_open(engine, state);
6    register_pane_move_tab(engine, state);
7}
8
9fn register_pane_open(engine: &Arc<WshRpcEngine>, state: &AppState) {
10    let state = state.clone();
11    engine.register_typed(COMMAND_PANE_OPEN, move |mut cmd: CommandPaneOpenData, ctx| {
12        let state = state.clone();
13        async move {
14            if let Some(meta) = cmd.meta.as_mut() {
15                crate::server::browser_owner::strip_srv_only_keys(meta);
16                // A browser tab's jar: checked here, where new tabs arrive.
17                crate::server::browser_identity::check_new_tab(&state, meta)?;
18                // An agent browses as a named profile only if the user lets it.
19                if !ctx.agent_id.is_empty() {
20                    crate::server::browser_identity::check_agent_may_use(meta)?;
21                }
22            }
23            open_pane(&state, cmd).await
24        }
25    });
26}
27
28/// SPEC_PANE_TAB_DRAG_AND_DROP_2026_09_19.md §4.1, Phase 3.
29fn register_pane_move_tab(engine: &Arc<WshRpcEngine>, state: &AppState) {
30    let state = state.clone();
31    engine.register_typed(COMMAND_PANE_MOVE_TAB, move |cmd: CommandPaneMoveTabData, _ctx| {
32        let state = state.clone();
33        async move { super::move_tab(&state, cmd).await }
34    });
35}
36
37/// Floating-pane branch of `open_pane`. The block already exists in
38/// `source_tab_id`'s blockids (created by the caller, with no layout node).
39/// This moves it into a fresh floating workspace via the `tear_off_block`
40/// saga, sets up the new tab's layout, broadcasts the new MuxObjs, and asks
41/// the source window's frontend to materialize the chromeless floating OS
42/// window via the host `open_floating_pane_window` command (srv cannot open
43/// windows itself). See docs/specs/SPEC_OPENEDITOR_FLOATING_AND_COLLAPSED_TREE_2026_06_16.md.
44pub(super) async fn open_pane_floating(
45    state: &AppState,
46    mstore: &Store,
47    event_bus: &crate::backend::eventbus::EventBus,
48    view: String,
49    source_tab_id: String,
50    meta: MetaMapType,
51) -> Result<PaneOpenResult, String> {
52    use agentmux_common::ipc::{Command, Event};
53
54    // Source workspace from the reducer's canonical tab→workspace map.
55    let source_ws_id = {
56        let s = state.srv_state.lock().await;
57        s.tabs
58            .get(&source_tab_id)
59            .map(|t| t.workspace_id.clone())
60            .ok_or_else(|| format!("pane.open: floating: tab {source_tab_id} not in reducer state"))?
61    };
62
63    // Create the block through the reducer (NOT wcore-direct) so it lands in
64    // `state.blocks` — the `tear_off_block` saga's pre-condition checks the
65    // reducer-canonical block map. The `BlockCreated` event also carries the
66    // meta, which `persist_subscriber::apply_block_created` writes into the
67    // mstore Block so the editor renders with its file + tree state. We skip
68    // layout placement, so the block never renders docked before the saga
69    // moves it into the floating workspace (no flash).
70    let meta_val = serde_json::to_value(&meta)
71        .map_err(|e| format!("pane.open: floating: meta serialize: {e}"))?;
72    let create_events = crate::server::service::dispatch_to_reducer(
73        state,
74        Command::CreateBlock {
75            tab_id: source_tab_id.clone(),
76            meta: meta_val,
77        },
78    )
79    .await;
80    if let Some(msg) = create_events.iter().find_map(|e| match e {
81        Event::Error { message, .. } => Some(message.clone()),
82        _ => None,
83    }) {
84        return Err(format!("pane.open: floating: CreateBlock: {msg}"));
85    }
86    let block_id = create_events
87        .iter()
88        .find_map(|e| match e {
89            Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
90            _ => None,
91        })
92        .ok_or_else(|| "pane.open: floating: CreateBlock emitted no BlockCreated".to_string())?;
93    for ev in &create_events {
94        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, mstore) {
95            tracing::warn!("pane.open: floating: CreateBlock mstore apply failed: {e}");
96        }
97    }
98    crate::server::service::publish_events(state, &create_events);
99
100    // Tear the block off into a fresh floating workspace + tab (reuses the
101    // exact saga the drag tear-off uses: CreateWorkspace → CreateTab → MoveBlock).
102    let saga_val = crate::sagas::tear_off_block::run(
103        state,
104        block_id.clone(),
105        source_tab_id.clone(),
106        source_ws_id.clone(),
107    )
108    .await?;
109    let new_ws_id = saga_val
110        .get("new_workspace_id")
111        .and_then(|v| v.as_str())
112        .unwrap_or_default()
113        .to_string();
114    let new_tab_id = saga_val
115        .get("new_tab_id")
116        .and_then(|v| v.as_str())
117        .unwrap_or_default()
118        .to_string();
119    if new_ws_id.is_empty() || new_tab_id.is_empty() {
120        return Err("pane.open: floating: tear_off_block returned empty ids".to_string());
121    }
122
123    // Make the moved block the new tab's single root node so it renders.
124    if let Err(e) =
125        crate::server::service::setup_torn_off_block_layout(state, &new_tab_id, &block_id).await
126    {
127        tracing::warn!(
128            new_tab = %new_tab_id,
129            "pane.open: floating: layout setup failed: {e} (block moved but layout malformed)"
130        );
131    }
132
133    // Broadcast the new workspace + layout + tab + block so any frontend syncs
134    // its MuxObj cache (mirrors the docked path + the tear-off DnD handler).
135    {
136        let mut updates: Vec<obj::MuxObjUpdate> = Vec::new();
137        if let Ok(ws) = mstore.must_get::<Workspace>(&new_ws_id) {
138            updates.push(obj::MuxObjUpdate {
139                updatetype: "update".into(),
140                otype: "workspace".into(),
141                oid: new_ws_id.clone(),
142                obj: Some(obj::mux_obj_to_value(&ws)),
143            });
144        }
145        if let Ok(t) = mstore.must_get::<Tab>(&new_tab_id) {
146            if let Ok(layout) = mstore.must_get::<obj::LayoutState>(&t.layoutstate) {
147                updates.push(obj::MuxObjUpdate {
148                    updatetype: "update".into(),
149                    otype: "layout".into(),
150                    oid: t.layoutstate.clone(),
151                    obj: Some(obj::mux_obj_to_value(&layout)),
152                });
153            }
154            updates.push(obj::MuxObjUpdate {
155                updatetype: "update".into(),
156                otype: "tab".into(),
157                oid: new_tab_id.clone(),
158                obj: Some(obj::mux_obj_to_value(&t)),
159            });
160        }
161        if let Ok(b) = mstore.must_get::<Block>(&block_id) {
162            updates.push(obj::MuxObjUpdate {
163                updatetype: "update".into(),
164                otype: "block".into(),
165                oid: block_id.clone(),
166                obj: Some(obj::mux_obj_to_value(&b)),
167            });
168        }
169        // One batched frame so the renderer applies all of them in a single
170        // reactive flush — see EventBus::broadcast_mux_obj_updates.
171        event_bus.broadcast_mux_obj_updates(&updates);
172    }
173
174    // Ask the source window's frontend to open the floating OS window — scoped
175    // to that window (mirrors the window-scoped `userinput` event) so exactly
176    // one window acts. The frontend handler calls the host
177    // `open_floating_pane_window` command.
178    let window_id = {
179        let s = state.srv_state.lock().await;
180        s.windows
181            .iter()
182            .find(|(_, w)| w.workspace_id == source_ws_id)
183            .map(|(id, _)| id.clone())
184    };
185    match window_id {
186        Some(win) => {
187            let open = crate::backend::mps_events::OpenFloatingPaneEvent { block_id: block_id.to_string(), workspace_id: new_ws_id.to_string() };
188            state.broker.publish_event::<crate::backend::mps_events::OpenFloatingPane>(&open, vec![win], 0);
189        }
190        None => {
191            tracing::warn!(
192                source_ws = %source_ws_id,
193                "pane.open: floating: no window mapped to source workspace — floater not opened"
194            );
195        }
196    }
197
198    Ok(PaneOpenResult {
199        block_id,
200        tab_id: new_tab_id,
201        view,
202        created: true,
203    })
204}
205
206/// Build the metadata map for a pane.open request, validating required args.
207pub(crate) fn build_pane_meta(cmd: &CommandPaneOpenData) -> Result<MetaMapType, String> {
208    let mut meta = MetaMapType::new();
209
210    match cmd.view.as_str() {
211        "editor" => {
212            let file = cmd.file.as_deref().filter(|s| !s.is_empty())
213                .ok_or_else(|| "MISSING_ARG: view=editor requires 'file'".to_string())?;
214            meta.insert("view".to_string(), json!("editor"));
215            meta.insert("file".to_string(), json!(file));
216
217            let lower = file.to_ascii_lowercase();
218            let is_markdown = lower.ends_with(".md") || lower.ends_with(".mdx");
219
220            // Tree state: explicit caller value wins; for markdown default to
221            // collapsed so the rendered preview gets full horizontal width.
222            if let Some(expanded) = cmd.tree_expanded {
223                meta.insert("editor:tree_expanded".to_string(), json!(expanded));
224            } else if is_markdown {
225                meta.insert("editor:tree_expanded".to_string(), json!(false));
226            }
227            if let Some(line) = cmd.line.filter(|&n| n > 0) {
228                meta.insert("editor:line".to_string(), json!(line));
229            }
230        }
231        "term" => {
232            meta.insert("view".to_string(), json!("term"));
233            meta.insert("controller".to_string(), json!("shell"));
234            if let Some(cwd) = cmd.cwd.as_deref().filter(|s| !s.is_empty()) {
235                meta.insert("cmd:cwd".to_string(), json!(cwd));
236            }
237        }
238        "browser" => {
239            let url = cmd.url.as_deref().filter(|s| !s.is_empty())
240                .ok_or_else(|| "MISSING_ARG: view=browser requires 'url'".to_string())?;
241            meta.insert("view".to_string(), json!("browser"));
242            meta.insert("url".to_string(), json!(url));
243        }
244        "sysinfo" => {
245            meta.insert("view".to_string(), json!("sysinfo"));
246        }
247        "help" => {
248            meta.insert("view".to_string(), json!("help"));
249        }
250        // Tower, the read-only task manager. Opening one is harmless: its data
251        // goes only to the window (`tower.*` refuses agents).
252        "tower" => {
253            meta.insert("view".to_string(), json!("tower"));
254        }
255        "media" => {
256            let file = cmd.file.as_deref().filter(|s| !s.is_empty())
257                .ok_or_else(|| "MISSING_ARG: view=media requires 'file'".to_string())?;
258            meta.insert("view".to_string(), json!("media"));
259            meta.insert("media:path".to_string(), json!(file));
260        }
261        // The Files pane ("Hangar"). The folder comes from `file`, else
262        // `cwd`, else home; `select` becomes a one-shot `files:select` the
263        // view applies after its first listing and then clears.
264        // SPEC_FILE_BROWSER_PANE_2026_10_01.md §6.2, §8.1.
265        "files" => {
266            let path = cmd
267                .file
268                .as_deref()
269                .filter(|s| !s.is_empty())
270                .or_else(|| cmd.cwd.as_deref().filter(|s| !s.is_empty()))
271                .map(str::to_string)
272                .or_else(|| dirs::home_dir().map(|h| h.to_string_lossy().into_owned()))
273                .ok_or_else(|| "MISSING_ARG: view=files requires 'file' (no home directory to default to)".to_string())?;
274            meta.insert("view".to_string(), json!("files"));
275            meta.insert("files:path".to_string(), json!(path));
276            if let Some(select) = cmd.select.as_ref().filter(|s| !s.is_empty()) {
277                meta.insert("files:select".to_string(), json!(select));
278            }
279        }
280        other => {
281            return Err(format!(
282                "INVALID_VIEW: unsupported view '{other}' (expected editor/term/browser/sysinfo/help/media/files/tower)"
283            ));
284        }
285    }
286
287    if let Some(title) = cmd.title.as_deref().filter(|s| !s.is_empty()) {
288        meta.insert("frame:title".to_string(), json!(title));
289    }
290
291    // The connection the pane works on, for the views that have one; media
292    // reads this computer's files only.
293    if let Some(conn) = cmd.connection.as_deref().map(str::trim).filter(|c| !c.is_empty() && *c != "local") {
294        match cmd.view.as_str() {
295            "editor" | "term" | "files" => {
296                meta.insert("connection".to_string(), json!(conn));
297            }
298            "media" if crate::backend::fs_ops::remote::ssh_connection(Some(conn)).is_some() => {
299                return Err(format!("INVALID_ARG: media files on {conn} can't be shown yet"));
300            }
301            _ => {}
302        }
303    }
304    Ok(meta)
305}
306
307/// Block-meta key carrying an ARRAY of files the reused pane should open —
308/// the sole delivery path (see below for why an earlier version's second,
309/// "live MPS event" path was removed). Drained (all entries, in order)
310/// reactively by `EditorViewModel`'s `createEffect` over its own block meta,
311/// then cleared immediately after — covers both "not yet mounted when this
312/// was written" and "already mounted, reacts as soon as the write lands"
313/// uniformly through the same MuxObj sync path the pane already depends on
314/// for everything else.
315///
316/// **Array, not a single scalar** (codex P1 on PR #2404): if 2+ `OpenEditor`
317/// reuse calls arrive before the target pane ever mounts, a single-value key
318/// would have each call overwrite the last, silently losing every request
319/// but the final one. Appending to an array and draining all of them at
320/// once fixes that.
321///
322/// **Sole delivery path — no separate live MPS event** (codex P1 on PR
323/// #2404, found twice): an earlier version ALSO fired a direct MPS event
324/// (`persist: 0`) alongside this meta write, for immediate delivery when the
325/// pane was already mounted. First finding: the frontend's live handler
326/// didn't clear its own entry from this array, so it could be reprocessed
327/// on a later, unrelated remount. Second, deeper finding after fixing that:
328/// the MPS event is a direct WS push and arrives essentially synchronously,
329/// while THIS meta write only reaches the frontend's `blockAtom` after an
330/// async MuxObj DB-refetch — so the live handler's own dequeue attempt
331/// could run and read stale data (this exact write not yet reflected)
332/// before it landed, no-op, and strand the entry anyway. Removing the
333/// separate live path entirely (rather than patching a second-order race in
334/// its own race-fix) leaves one delivery mechanism and one reactive
335/// consumer — nothing to race. Trades a small amount of latency for the
336/// already-mounted case (a real MuxObj round-trip instead of a direct
337/// push) for not being racy.
338///
339/// **Superseded relying on MPS `persist > 0` for durability** (codex P1 on
340/// PR #2404, earliest finding on this function): a just-created Editor
341/// block may not have finished mounting its `EditorViewModel` by the time a
342/// second back-to-back `OpenEditor` call reuses it. `persist: N` closes that
343/// race but opens a *worse* one: `Broker::unsubscribe_all` clears a route's
344/// replay marker on disconnect (`crates/srv/src/backend/mps.rs`),
345/// so any later, unrelated reconnect would replay the *entire* persisted
346/// history again — reopening files the user has since closed. The broker
347/// has no ack/consume concept, so nothing marks a persisted event "already
348/// delivered." Block meta does have exactly that shape (write once, drain
349/// once, clear after reading) — reusing it avoids inventing new broker
350/// machinery.
351const META_PENDING_OPEN_FILES: &str = "editor:pending_open_files";
352
353/// If the calling agent (identified by its own block id, `caller_block_id`)
354/// already has an Editor pane open in its own tab, push `file` into that
355/// pane as a new tab instead of creating another Editor pane. Returns
356/// `Ok(None)` when there's no existing Editor pane to reuse (or the caller's
357/// tab can't be resolved) — the normal create-new-block path handles that
358/// case unchanged.
359///
360/// **Known, accepted limitation: does not apply layout focus.** An earlier
361/// version of this function resolved the reused block's layout leaf id and
362/// dispatched `Command::SetFocusedNode` — reagent (PR #2404) confirmed the
363/// leaf-id resolution itself was correct, then found a deeper problem:
364/// the frontend's `onBackendUpdate` (`frontend/layout/lib/layoutPersistence.ts`)
365/// only re-derives `focusedNodeId` at initial model construction or via a
366/// `pendingBackendActions`-driven tree action — a bare `focusednodeid`
367/// MuxObj push to an ALREADY-MOUNTED `LayoutModel` (confirmed by reading
368/// the function directly: no branch reads `muxObj.focusednodeid` outside
369/// those two triggers) is silently never applied to the live `treeState`.
370/// Making that work would mean changing `onBackendUpdate`'s reactivity —
371/// shared code this file's own comments show has deliberately been kept
372/// narrow/non-reactive before (see the dangling-leaf-prune history right
373/// above it) — a real, separate piece of work, not a one-line fix. Shipping
374/// a backend dispatch that compiles and passes a reducer-internal test but
375/// has no visible frontend effect would be worse than not attempting it: it
376/// would look done without being done. The reused pane's new tab still
377/// becomes its *own* active tab via `openFile()`; only the cross-pane
378/// layout-focus indicator is unaffected, same as before this feature
379/// existed.
380pub(super) async fn maybe_reuse_editor_pane(
381    state: &AppState,
382    caller_block_id: &str,
383    file: &str,
384    connection: &str,
385) -> Result<Option<PaneOpenResult>, String> {
386    let mstore = &state.mstore;
387    let tab_id = match super::resolve_tab_id_for_block(mstore, caller_block_id) {
388        Ok(id) => id,
389        Err(_) => return Ok(None), // caller's own block isn't in any known tab — fall through
390    };
391
392    // Only an editor on the file's own connection: one on a host reads and
393    // saves there, so a local file pushed into it would be read there.
394    let existing = match super::find_editor_block(mstore, &tab_id, connection)? {
395        Some(block) => block,
396        None => return Ok(None),
397    };
398
399    // Durable delivery path: append to any already-pending queue (read then
400    // write — a small race window under truly concurrent reuse calls is
401    // accepted, matching this codebase's general best-effort meta-patch
402    // posture elsewhere) so a not-yet-mounted (or remounting)
403    // EditorViewModel drains every pending file once at construction, then
404    // clears the queue — see META_PENDING_OPEN_FILES's doc comment for why
405    // this replaces relying on MPS persist/replay for correctness.
406    let mut pending: Vec<String> = existing
407        .meta
408        .get(META_PENDING_OPEN_FILES)
409        .and_then(|v| v.as_array())
410        .map(|arr| arr.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
411        .unwrap_or_default();
412    pending.push(file.to_string());
413
414    let meta_events = crate::server::service::dispatch_to_reducer(
415        state,
416        agentmux_common::ipc::Command::UpdateBlockMeta {
417            block_id: existing.oid.clone(),
418            meta_patch: json!({ META_PENDING_OPEN_FILES: pending }),
419        },
420    )
421    .await;
422    for ev in &meta_events {
423        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, mstore) {
424            tracing::warn!("pane.open: reuse: UpdateBlockMeta mstore apply failed: {e}");
425        }
426    }
427    crate::server::service::publish_events(state, &meta_events);
428
429    tracing::info!(
430        block_id = %existing.oid,
431        tab_id = %tab_id,
432        "pane.open: reused existing editor pane instead of creating a new one"
433    );
434
435    Ok(Some(PaneOpenResult {
436        block_id: existing.oid,
437        tab_id,
438        view: "editor".to_string(),
439        created: false,
440    }))
441}
442
443/// Translate `split_direction` + `split_reference_block_id` into the backend
444/// layout action triple. Returns `(actiontype, targetblockid, position)`.
445/// Falls back to a plain `insert` if direction/reference are missing.
446pub(super) fn resolve_placement(
447    direction: Option<&str>,
448    reference: Option<&str>,
449) -> (String, String, String) {
450    let reference = match reference.filter(|s| !s.is_empty()) {
451        Some(r) => r,
452        None => return ("insert".to_string(), String::new(), String::new()),
453    };
454
455    let (actiontype, position) = match direction {
456        Some("right") => (crate::backend::wcore::LAYOUT_ACTION_SPLIT_HORIZONTAL, "after"),
457        Some("left") => (crate::backend::wcore::LAYOUT_ACTION_SPLIT_HORIZONTAL, "before"),
458        Some("down") | Some("below") => (crate::backend::wcore::LAYOUT_ACTION_SPLIT_VERTICAL, "after"),
459        Some("up") | Some("above") => (crate::backend::wcore::LAYOUT_ACTION_SPLIT_VERTICAL, "before"),
460        _ => return ("insert".to_string(), String::new(), String::new()),
461    };
462
463    (actiontype.to_string(), reference.to_string(), position.to_string())
464}
465
466/// `POST /api/v1/agent/pane/close` — backs the `ClosePane` MCP tool.
467/// See docs/specs/SPEC_AGENT_PANE_LIFECYCLE_CONTROL_2026_09_10.md.
468///
469/// Identity is always verified first via the same `verified_block_id`
470/// mechanism `UIClick`/`UIQuery`/`UIScreenshot` use (§5.0 of that spec) —
471/// this both resolves the caller's OWN pane when `req.block_id` is absent,
472/// and supplies a real, checked `source_agent` for the audit log when
473/// `req.block_id` targets another agent's pane (fleet tier, §5.1/§5.2): no
474/// ownership check on the TARGET (closing another agent's stuck pane without
475/// needing its cooperation is this spec's whole motivation), but the CALLER
476/// is never anonymous the way `FleetBulkStop`'s existing calls are today.
477/// `POST /api/v1/agent/self/quit` — the `QuitSelf` tool
478/// (docs/specs/SPEC_AGENT_SELF_QUIT_2026_09_24.md §6). Always the caller's
479/// own block (from the signed `auth`); there is no target. Proceeds only
480/// when the user started the current turn, nothing else got in, and the
481/// quote is theirs (§6.3). The quit then waits for the turn to end (§4.2):
482/// 202 now, the tab closes after. Anything else is an external shutdown: 202
483/// `pending_user_override`, and the user has 15 s to keep the agent (§6.5).
484pub(crate) async fn handle_quit_self(
485    axum::extract::State(state): axum::extract::State<AppState>,
486    caller: Option<axum::Extension<crate::server::caller::Caller>>,
487    axum::Json(req): axum::Json<agentmux_common::api_types::QuitSelfRequest>,
488) -> impl axum::response::IntoResponse {
489    use axum::http::StatusCode;
490    use axum::response::IntoResponse;
491    use axum::Json;
492
493    let block_id = match crate::server::ui_handlers::verified_block_id(&state, caller.as_deref(), &req.auth) {
494        Ok(b) => b,
495        Err(e) => return (StatusCode::UNAUTHORIZED, Json(json!({ "error": e }))).into_response(),
496    };
497    quit_self(&state, &block_id, &req.auth.agent_id, "QuitSelf", req.reason.trim(), &req.user_instruction)
498}
499
500/// An agent ending its own session — `QuitSelf`, or `ClosePane` with no
501/// arguments (§7), which has no quote and so always asks the user. A
502/// user-started, untainted turn with the user's quote quits when the turn
503/// ends; anything else waits 15 s for the user to keep it (§6.5).
504fn quit_self(
505    state: &AppState,
506    block_id: &str,
507    agent: &str,
508    via: &str,
509    reason: &str,
510    user_instruction: &str,
511) -> axum::response::Response {
512    use crate::sagas::self_quit;
513    use axum::http::StatusCode;
514    use axum::response::IntoResponse;
515    use axum::Json;
516
517    let detail = format!("{reason} | user said: {user_instruction:?}");
518    // Scheduled, closing, or a user-override window that already ran out.
519    let under_way = crate::sagas::pending_shutdown::active_for(block_id).is_some_and(|v| v.status == "proceeding");
520    if self_quit::is_quitting(block_id) || under_way {
521        return (StatusCode::OK, Json(json!({ "status": "already_quitting" }))).into_response();
522    }
523    let provenance = crate::backend::blockcontroller::get_controller(block_id).and_then(|c| c.turn_provenance());
524    if let Err(refusal) = self_quit::gate(provenance.as_ref(), user_instruction) {
525        // Not the user's own ask: the user decides, within 15 s (§6.5). Audited
526        // with why the gate didn't pass (§4.4); the outcome is audited too.
527        let pending = crate::sagas::pending_shutdown::request(
528            state,
529            block_id,
530            agent,
531            via,
532            reason,
533            crate::sagas::pending_shutdown::Action::SelfQuit { detail: detail.clone() },
534        );
535        state.reactive_handler.log_fleet_action_audit(
536            Some(agent),
537            agent,
538            block_id,
539            "agent.quit_self",
540            true,
541            Some(refusal.as_str()),
542            &pending.request_id,
543            Some(&format!("{} ({via}): {detail}", crate::sagas::pending_shutdown::audit_note(&pending, agent, via))),
544        );
545        return (StatusCode::ACCEPTED, Json(pending_body(&pending))).into_response();
546    }
547    if !self_quit::schedule_after_turn(state, block_id, detail) {
548        return (StatusCode::OK, Json(json!({ "status": "already_quitting" }))).into_response();
549    }
550    (StatusCode::ACCEPTED, Json(json!({ "status": "scheduled" }))).into_response()
551}
552
553/// The 202 answer for a shutdown waiting on the user (§6.5).
554pub(crate) fn pending_body(p: &crate::sagas::pending_shutdown::PendingView) -> serde_json::Value {
555    json!({
556        "status": "pending_user_override",
557        "request_id": p.request_id,
558        // Whose request this is: a caller who finds someone else here joined it.
559        "by": p.by,
560        "via": p.via,
561        "deadline_ms": p.deadline_ms,
562        "wait_at_least_ms": crate::sagas::pending_shutdown::OVERRIDE_WINDOW.as_millis() as u64,
563    })
564}
565
566/// `GET /api/v1/agent/shutdown/{request_id}` — where a shutdown waiting on the
567/// user's override stands (§6.5): `pending`, `kept_by_user`, `proceeding`,
568/// `shut_down`, `superseded` or `failed`.
569pub(crate) async fn handle_shutdown_status(
570    axum::extract::State(state): axum::extract::State<AppState>,
571    axum::extract::Path(request_id): axum::extract::Path<String>,
572) -> impl axum::response::IntoResponse {
573    use axum::http::StatusCode;
574    use axum::response::IntoResponse;
575    use axum::Json;
576    if let Some(v) = crate::sagas::pending_shutdown::status(&request_id) {
577        return (StatusCode::OK, Json(serde_json::to_value(v).unwrap_or_default())).into_response();
578    }
579    // Asked on another instance on this machine (a cross-channel
580    // FleetBulkStop target): that instance holds the answer.
581    if let Some(remote) = crate::sagas::pending_shutdown::remote_for(&request_id) {
582        let url = format!("{}/api/v1/agent/shutdown/{request_id}", remote.local_url.trim_end_matches('/'));
583        let mut req = state.http_client.get(&url);
584        if !remote.auth_key.is_empty() {
585            req = req.header(AUTH_KEY_HEADER, &remote.auth_key);
586        }
587        return match req.send().await {
588            Ok(r) => {
589                let code = StatusCode::from_u16(r.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
590                let body: serde_json::Value = r.json().await.unwrap_or_default();
591                (code, Json(body)).into_response()
592            }
593            Err(e) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": format!("its AgentMux instance: {e}") }))).into_response(),
594        };
595    }
596    (StatusCode::NOT_FOUND, Json(json!({ "error": "no such shutdown request" }))).into_response()
597}
598
599pub(crate) async fn handle_close_pane(
600    axum::extract::State(state): axum::extract::State<AppState>,
601    caller: Option<axum::Extension<crate::server::caller::Caller>>,
602    axum::Json(req): axum::Json<agentmux_common::api_types::ClosePaneRequest>,
603) -> impl axum::response::IntoResponse {
604    use axum::http::StatusCode;
605    use axum::response::IntoResponse;
606    use axum::Json;
607
608    let caller_agent_id = req.auth.agent_id.clone();
609    let verified_own_block_id = match crate::server::ui_handlers::verified_block_id(&state, caller.as_deref(), &req.auth) {
610        Ok(b) => b,
611        Err(e) => return (StatusCode::UNAUTHORIZED, Json(json!({ "error": e }))).into_response(),
612    };
613
614    // No arguments: the agent closing itself. That is `QuitSelf` without its
615    // quote (§7): its own tab only, siblings keep running, and only after the
616    // user's 15 s window. Closing the whole pane takes an explicit block_id.
617    if req.block_id.is_none() {
618        let reason = req.reason.clone().unwrap_or_default();
619        return quit_self(&state, &verified_own_block_id, &caller_agent_id, "ClosePane", reason.trim(), "");
620    }
621
622    let target = req.block_id.clone().unwrap_or_else(|| verified_own_block_id.clone());
623    let (code, body) = close_pane_as(&state, &caller_agent_id, &verified_own_block_id, &target, req.reason.as_deref(), "ClosePane").await;
624    (code, Json(body)).into_response()
625}
626
627/// Close the pane that holds `target_block_id` on behalf of `caller_agent_id`
628/// (whose own pane is `own_block_id`), the way ClosePane does: a pane the
629/// caller is in closes at once; another agent's pane waits 15 s for its user
630/// to keep it (§6.5) and answers 202 with the pending request. Audited as
631/// `pane.close`. Shared by ClosePane and RunCommand's `pane:close`
632/// (`server/ui_shortcuts.rs`), so both carry the same protection. `via` names
633/// the tool in the pending request and the audit.
634pub(crate) async fn close_pane_as(
635    state: &AppState,
636    caller_agent_id: &str,
637    own_block_id: &str,
638    target_block_id: &str,
639    reason: Option<&str>,
640    via: &str,
641) -> (axum::http::StatusCode, serde_json::Value) {
642    use axum::http::StatusCode;
643    let target_block_id = target_block_id.to_string();
644    let verified_own_block_id = own_block_id.to_string();
645    let caller_agent_id = caller_agent_id.to_string();
646
647    // codex P2 on PR #3193: resolve the target's agent name BEFORE deleting
648    // the block, not after. `delete_block::run` stops the block's
649    // controller, and a running agent's own exit-triggered
650    // `unregister_block` can race ahead of this lookup once it does — a
651    // post-delete lookup would then fall back to the bare block UUID
652    // instead of the real agent name, making attribution timing-dependent.
653    let target_agent = state
654        .reactive_handler
655        .get_agent_by_block(&target_block_id)
656        .map(|a| a.agent_id)
657        .unwrap_or_else(|| target_block_id.clone());
658
659    // The whole pane the target lives in — every member of its tab stack, not
660    // just the target (#3202; SPEC_AGENT_PANE_CLOSE_GRACEFUL_SHUTDOWN_2026_09_18.md §4.1).
661    let pane_block_ids = {
662        let s = state.srv_state.lock().await;
663        match s.blocks.get(&target_block_id) {
664            Some(b) => s
665                .tabs
666                .get(&b.tab_id)
667                .and_then(|t| t.rootnode.as_ref())
668                .and_then(|root| {
669                    crate::backend::layout::find_leaf_containing_block(root, &target_block_id)
670                })
671                .and_then(|leaf| leaf.data.as_ref())
672                .map(crate::backend::layout::leaf_members)
673                .unwrap_or_else(|| vec![target_block_id.clone()]),
674            None => {
675                let err = format!("block not found: {target_block_id}");
676                let request_id = uuid::Uuid::new_v4().to_string();
677                state.reactive_handler.log_fleet_action_audit(
678                    Some(&caller_agent_id), &target_agent, &target_block_id,
679                    "pane.close", false, Some(&err), &request_id, reason,
680                );
681                return (StatusCode::NOT_FOUND, json!({ "error": err }));
682            }
683        }
684    };
685
686    // Another agent's pane: its user has 15 s to keep it (§6.5). Closing a
687    // pane you are in yourself is your own business.
688    if !pane_block_ids.contains(&verified_own_block_id) {
689        let reason = reason.unwrap_or_default().to_string();
690        let pending = crate::sagas::pending_shutdown::request(
691            state,
692            &target_block_id,
693            &caller_agent_id,
694            via,
695            &reason,
696            crate::sagas::pending_shutdown::Action::ClosePane { block_ids: pane_block_ids },
697        );
698        state.reactive_handler.log_fleet_action_audit(
699            Some(&caller_agent_id),
700            &target_agent,
701            &target_block_id,
702            "pane.close",
703            true,
704            None,
705            &pending.request_id,
706            Some(&format!("{}: {reason}", crate::sagas::pending_shutdown::audit_note(&pending, &caller_agent_id, via))),
707        );
708        return (StatusCode::ACCEPTED, pending_body(&pending));
709    }
710
711    let result = crate::sagas::close_pane::run(state, pane_block_ids).await;
712
713    let request_id = uuid::Uuid::new_v4().to_string();
714    match &result {
715        Ok(_) => state.reactive_handler.log_fleet_action_audit(
716            Some(&caller_agent_id), &target_agent, &target_block_id,
717            "pane.close", true, None, &request_id, reason,
718        ),
719        Err(e) => state.reactive_handler.log_fleet_action_audit(
720            Some(&caller_agent_id), &target_agent, &target_block_id,
721            "pane.close", false, Some(e), &request_id, reason,
722        ),
723    }
724
725    match result {
726        Ok(v) => (StatusCode::OK, v),
727        Err(e) => (StatusCode::BAD_REQUEST, json!({ "error": e })),
728    }
729}
730
731#[cfg(test)]
732mod close_pane_tests {
733    use super::*;
734    use axum::response::IntoResponse;
735    use crate::server::tests::test_state;
736    use agentmux_common::api_types::{ClosePaneRequest, UiAutomationAuth};
737    use agentmux_common::ipc::{Command, Event};
738
739    async fn dispatch_apply(state: &AppState, cmd: Command) -> Vec<Event> {
740        let evs = crate::server::service::dispatch_to_reducer(state, cmd).await;
741        for ev in &evs {
742            crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
743        }
744        evs
745    }
746
747    /// Seed a workspace + tab + block, same shape as
748    /// `sagas::delete_block::tests::seed` — duplicated rather than shared
749    /// across modules (both are small, private `#[cfg(test)]` helpers).
750    async fn seed(state: &AppState) -> (String, String) {
751        let ws_evs = dispatch_apply(state, Command::CreateWorkspace { name: "w".into() }).await;
752        let ws_id = ws_evs
753            .iter()
754            .find_map(|e| match e {
755                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
756                _ => None,
757            })
758            .unwrap();
759        let tab_evs = dispatch_apply(
760            state,
761            Command::CreateTab { workspace_id: ws_id, name: "t".into() },
762        )
763        .await;
764        let tab_id = tab_evs
765            .iter()
766            .find_map(|e| match e {
767                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
768                _ => None,
769            })
770            .unwrap();
771        let blk_evs = dispatch_apply(
772            state,
773            Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
774        )
775        .await;
776        let block_id = blk_evs
777            .iter()
778            .find_map(|e| match e {
779                Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
780                _ => None,
781            })
782            .unwrap();
783        (tab_id, block_id)
784    }
785
786    /// Mint a real signing key for `agent_id` (same store call
787    /// `agent_open`/spawn use) and sign a fresh `UiAutomationAuth` for it —
788    /// the exact mechanism `agentmux-mcp`'s `sign_ui_automation_auth` uses,
789    /// reproduced here since this is a different crate.
790    fn sign_auth(state: &AppState, agent_id: &str) -> UiAutomationAuth {
791        let key = state.mstore.agent_jekt_key_ensure(agent_id).unwrap();
792        let ts_secs = agentmux_common::time::now_secs();
793        let sig = agentmux_common::jekt_sign::sign_jekt(
794            &key, "ui-automation-identity", agent_id, "__srv__", ts_secs, "",
795        );
796        UiAutomationAuth { agent_id: agent_id.to_string(), ts_secs, sig }
797    }
798
799    /// §7: with no arguments, ClosePane is the agent quitting itself — no
800    /// quote, so always the user's 15 s window, then its own tab only.
801    #[tokio::test]
802    async fn no_argument_close_is_a_self_quit_behind_the_user_s_window() {
803        let state = test_state();
804        let (tab_id, block_id) = seed(&state).await;
805        let sibling = dispatch_apply(&state, Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null })
806            .await
807            .iter()
808            .find_map(|e| match e {
809                Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
810                _ => None,
811            })
812            .unwrap();
813        let agent_id = format!("close-own-{}", uuid::Uuid::new_v4());
814        state.reactive_handler.register_agent(&agent_id, &block_id, Some(&tab_id)).unwrap();
815        let auth = sign_auth(&state, &agent_id);
816
817        let resp = handle_close_pane(
818            axum::extract::State(state.clone()),
819            None,
820            axum::Json(ClosePaneRequest { auth, block_id: None, reason: Some("done".into()) }),
821        )
822        .await
823        .into_response();
824        assert_eq!(resp.status(), axum::http::StatusCode::ACCEPTED);
825        let body = body_json(resp).await;
826        assert_eq!(body["status"], "pending_user_override", "no quote: the user is asked");
827        let request_id = body["request_id"].as_str().unwrap().to_string();
828        let pending = crate::sagas::pending_shutdown::status(&request_id).unwrap();
829        assert_eq!((pending.via.as_str(), pending.by.as_str()), ("ClosePane", agent_id.as_str()));
830        assert!(state.srv_state.lock().await.blocks.contains_key(&block_id), "still running during the window");
831
832        crate::sagas::pending_shutdown::expire_now(&request_id);
833        assert_eq!(crate::sagas::pending_shutdown::settled(&request_id).await.status, "shut_down");
834        let s = state.srv_state.lock().await;
835        assert!(!s.blocks.contains_key(&block_id), "its own tab is gone");
836        assert!(s.blocks.contains_key(&sibling), "a sibling tab keeps running");
837    }
838
839    #[tokio::test]
840    async fn cross_pane_close_removes_the_target_and_logs_the_verified_caller() {
841        let state = test_state();
842        // Caller: registered with its OWN pane, but acts on someone else's.
843        let (caller_tab, caller_block) = seed(&state).await;
844        let caller_id = format!("close-caller-{}", uuid::Uuid::new_v4());
845        state.reactive_handler.register_agent(&caller_id, &caller_block, Some(&caller_tab)).unwrap();
846        let auth = sign_auth(&state, &caller_id);
847
848        // Target: a different agent's pane, entirely unrelated to the caller.
849        let (target_tab, target_block) = seed(&state).await;
850        let target_id = format!("close-target-{}", uuid::Uuid::new_v4());
851        state.reactive_handler.register_agent(&target_id, &target_block, Some(&target_tab)).unwrap();
852
853        let resp = handle_close_pane(
854            axum::extract::State(state.clone()),
855            None,
856            axum::Json(ClosePaneRequest {
857                auth,
858                block_id: Some(target_block.clone()),
859                reason: Some("verifying the fix".to_string()),
860            }),
861        )
862        .await
863        .into_response();
864        assert_eq!(resp.status(), axum::http::StatusCode::ACCEPTED, "another agent's pane waits for its user");
865        let request_id = body_json(resp).await["request_id"].as_str().unwrap().to_string();
866        assert!(state.srv_state.lock().await.blocks.contains_key(&target_block), "still running during the window");
867        crate::sagas::pending_shutdown::expire_now(&request_id);
868        assert_eq!(crate::sagas::pending_shutdown::settled(&request_id).await.status, "shut_down");
869
870        // Target pane gone; caller's own pane untouched.
871        {
872            let s = state.srv_state.lock().await;
873            assert!(!s.blocks.contains_key(&target_block), "target pane must be closed");
874            assert!(s.blocks.contains_key(&caller_block), "caller's own pane must be untouched");
875        }
876
877        // Audit entry carries the CALLER's verified identity as source_agent —
878        // not None, the gap this spec's §5.2 fixed (FleetBulkStop still has
879        // it; this new call site must not repeat it).
880        // get_audit_log returns most-recent-first (its own doc comment) — no
881        // extra .rev() needed; the closed-block entry is naturally first.
882        let entries = state.reactive_handler.get_audit_log(50);
883        let entry = entries
884            .iter()
885            .find(|e| e.block_id == target_block)
886            .expect("expected an audit entry for the closed target block");
887        assert_eq!(entry.source_agent.as_deref(), Some(caller_id.as_str()));
888        assert_eq!(entry.target_agent, target_id);
889        assert!(entry.success);
890        let reason = entry.reason.as_deref().unwrap_or_default();
891        assert!(reason.starts_with("shut_down") && reason.contains("verifying the fix"), "{reason}");
892    }
893
894    #[tokio::test]
895    async fn keep_running_leaves_another_agent_s_pane_open() {
896        let state = test_state();
897        let (caller_tab, caller_block) = seed(&state).await;
898        let caller_id = format!("close-keep-caller-{}", uuid::Uuid::new_v4());
899        state.reactive_handler.register_agent(&caller_id, &caller_block, Some(&caller_tab)).unwrap();
900        let (_, target_block) = seed(&state).await;
901
902        let resp = handle_close_pane(
903            axum::extract::State(state.clone()),
904            None,
905            axum::Json(ClosePaneRequest {
906                auth: sign_auth(&state, &caller_id),
907                block_id: Some(target_block.clone()),
908                reason: None,
909            }),
910        )
911        .await
912        .into_response();
913        assert_eq!(resp.status(), axum::http::StatusCode::ACCEPTED);
914        let body = body_json(resp).await;
915        assert_eq!(body["status"], "pending_user_override");
916        assert_eq!(body["wait_at_least_ms"], 15_000);
917        let request_id = body["request_id"].as_str().unwrap().to_string();
918
919        assert_eq!(crate::sagas::pending_shutdown::keep(&state, &target_block, &request_id), "kept_by_user");
920        assert_eq!(crate::sagas::pending_shutdown::settled(&request_id).await.status, "kept_by_user");
921        assert!(state.srv_state.lock().await.blocks.contains_key(&target_block), "the user kept it");
922    }
923
924    async fn body_json(resp: axum::response::Response) -> serde_json::Value {
925        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
926        serde_json::from_slice(&bytes).unwrap()
927    }
928
929    /// #3202: closing a pane through the MCP tool closes every tab in it, not
930    /// just the targeted block — even when the target is a background tab.
931    #[tokio::test]
932    async fn close_closes_every_tab_in_the_target_s_pane() {
933        let state = test_state();
934        let (caller_tab, caller_block) = seed(&state).await;
935        let caller_id = format!("close-stack-caller-{}", uuid::Uuid::new_v4());
936        state.reactive_handler.register_agent(&caller_id, &caller_block, Some(&caller_tab)).unwrap();
937        let auth = sign_auth(&state, &caller_id);
938
939        let (tab_id, visible) = seed(&state).await;
940        let background = dispatch_apply(
941            &state,
942            Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
943        )
944        .await
945        .iter()
946        .find_map(|e| match e {
947            Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
948            _ => None,
949        })
950        .unwrap();
951        dispatch_apply(
952            &state,
953            Command::LayoutSetTree {
954                tab_id: tab_id.clone(),
955                new_tree: Some(agentmux_common::LayoutNode {
956                    id: "leaf".into(),
957                    data: Some(agentmux_common::LayoutNodeData {
958                        block_id: visible.clone(),
959                        block_stack: vec![visible.clone(), background.clone()],
960                        active_block_id: visible.clone(),
961                        ..Default::default()
962                    }),
963                    ..Default::default()
964                }),
965                correlation_id: String::new(),
966                slices: None,
967            },
968        )
969        .await;
970
971        let resp = handle_close_pane(
972            axum::extract::State(state.clone()),
973            None,
974            axum::Json(ClosePaneRequest { auth, block_id: Some(background.clone()), reason: None }),
975        )
976        .await
977        .into_response();
978        assert_eq!(resp.status(), axum::http::StatusCode::ACCEPTED);
979        let request_id = body_json(resp).await["request_id"].as_str().unwrap().to_string();
980        crate::sagas::pending_shutdown::expire_now(&request_id);
981        crate::sagas::pending_shutdown::settled(&request_id).await;
982
983        let s = state.srv_state.lock().await;
984        assert!(!s.blocks.contains_key(&visible), "visible tab closed with its pane");
985        assert!(!s.blocks.contains_key(&background), "background tab closed with its pane");
986        assert!(s.tabs[&tab_id].block_ids.is_empty());
987        assert!(s.tabs[&tab_id].rootnode.is_none(), "pane removed");
988        assert!(s.blocks.contains_key(&caller_block), "caller untouched");
989    }
990
991    #[tokio::test]
992    async fn rejects_an_invalid_signature() {
993        let state = test_state();
994        let (tab_id, block_id) = seed(&state).await;
995        let agent_id = format!("close-bad-sig-{}", uuid::Uuid::new_v4());
996        state.reactive_handler.register_agent(&agent_id, &block_id, Some(&tab_id)).unwrap();
997        // A signature signed with the WRONG key — this agent's real key was
998        // never used, simulating a forged/corrupted signature.
999        let bogus_key = vec![0u8; 32];
1000        let ts_secs = agentmux_common::time::now_secs();
1001        let sig = agentmux_common::jekt_sign::sign_jekt(
1002            &bogus_key, "ui-automation-identity", &agent_id, "__srv__", ts_secs, "",
1003        );
1004        let auth = UiAutomationAuth { agent_id, ts_secs, sig };
1005
1006        let resp = handle_close_pane(
1007            axum::extract::State(state.clone()),
1008            None,
1009            axum::Json(ClosePaneRequest { auth, block_id: None, reason: None }),
1010        )
1011        .await
1012        .into_response();
1013        assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED);
1014
1015        // Nothing was touched.
1016        let s = state.srv_state.lock().await;
1017        assert!(s.blocks.contains_key(&block_id));
1018    }
1019}
1020
1021/// Core `pane.open` logic, shared by the WebSocket RPC handler
1022/// (`register_pane_open`) and the HTTP route `POST /api/v1/pane/open`
1023/// (`agentmux-mcp`'s `OpenEditor` tool). Creates a block for the requested
1024/// view, enqueues a layout action (split or insert), and broadcasts the
1025/// block/tab/layout updates so the frontend renders the new pane.
1026pub async fn open_pane(state: &AppState, cmd: CommandPaneOpenData) -> Result<PaneOpenResult, String> {
1027    let mstore = state.mstore.clone();
1028    let event_bus = state.event_bus.clone();
1029
1030    tracing::info!(view = %cmd.view, "pane.open");
1031
1032    // Use caller-supplied meta when present (widget bar path: full blockdef.meta
1033    // already known); otherwise derive from view + args via build_pane_meta.
1034    let meta = match cmd.meta {
1035        Some(m) => m,
1036        None => pane::build_pane_meta(&cmd)?,
1037    };
1038
1039    // Editor-pane reuse (SPEC_EDITOR_MCP_OPEN_BLANK_PREVIEW_AND_PANE_REUSE_2026_08_03.md
1040    // Part 2): if the calling agent already has an Editor pane open in its own
1041    // tab, add the requested file as a new tab in that pane instead of always
1042    // spawning another Editor pane. Gated on the explicit `reuse_editor_pane`
1043    // opt-in only — NOT inferred from `meta`/`split_reference_block_id` being
1044    // present, since `EditorViewModel.openToTheSide`/`openInTerminal` also set
1045    // `split_reference_block_id` to their OWN block id for split placement and
1046    // must not trigger reuse (reagent P1 on PR #2404 caught an earlier version
1047    // of this check incorrectly reusing the calling pane itself for
1048    // `openToTheSide`). Also excludes `floating` requests — those always get
1049    // their own new window (codex P1 on PR #2404: this branch previously ran
1050    // before the floating check below and silently swallowed floating
1051    // requests into a reused docked pane). Also excludes an explicit
1052    // `tree_expanded` request (`OpenEditor`'s `collapse_tree` option) —
1053    // reagent P2 on PR #2404: a reused pane keeps ITS OWN existing tree
1054    // state, with no live mechanism to apply a new one (same class of
1055    // construction-time-only limitation as focus, see
1056    // `maybe_reuse_editor_pane`'s doc comment) — bypassing reuse for this
1057    // specific request and falling through to the create path (which
1058    // already honors `tree_expanded` correctly) is far simpler than
1059    // building live meta-application, and was the reviewer's own suggested
1060    // alternative.
1061    if cmd.view == "editor"
1062        && cmd.reuse_editor_pane == Some(true)
1063        && cmd.floating != Some(true)
1064        && cmd.tree_expanded.is_none()
1065    {
1066        if let (Some(caller_block_id), Some(file)) =
1067            (cmd.split_reference_block_id.as_deref(), cmd.file.as_deref())
1068        {
1069            let connection = cmd.connection.as_deref().unwrap_or("");
1070            if let Some(result) = pane::maybe_reuse_editor_pane(state, caller_block_id, file, connection).await? {
1071                return Ok(result);
1072            }
1073        }
1074    }
1075
1076    // Resolve tab: explicit tab_id wins; otherwise, if the caller told us
1077    // which block to place this pane relative to (split_reference_block_id),
1078    // resolve THAT block's own tab rather than falling back to "whichever
1079    // tab happens to be globally active" — a caller specifying a relative
1080    // block virtually always means "my own tab" (codex P2 on PR #2404: the
1081    // editor-reuse check above already resolves this correctly-scoped tab
1082    // for its own lookup, but previously discarded it whenever reuse didn't
1083    // apply — e.g. a floating request, or no existing Editor pane yet —
1084    // silently falling through to the flakier "first workspace's active
1085    // tab" heuristic for the actual block creation/placement below, which
1086    // can place a pane in the wrong tab entirely in a multi-tab setup).
1087    let tab_id = if let Some(explicit) = cmd.tab_id.as_deref() {
1088        explicit.to_string()
1089    } else if let Some(derived) = cmd
1090        .split_reference_block_id
1091        .as_deref()
1092        .and_then(|id| resolve_tab_id_for_block(&mstore, id).ok())
1093    {
1094        derived
1095    } else {
1096        resolve_tab_id(&mstore, None)?
1097    };
1098
1099    // Floating path (SPEC_OPENEDITOR_FLOATING_AND_COLLAPSED_TREE_2026_06_16):
1100    // create the block in a fresh floating workspace (via reducer CreateBlock +
1101    // the existing tear_off_block saga) and signal the source window's frontend
1102    // to materialize the chromeless OS window — srv can't open windows itself.
1103    if cmd.floating == Some(true) {
1104        return pane::open_pane_floating(state, &mstore, &event_bus, cmd.view, tab_id, meta).await;
1105    }
1106
1107    // Stack path (SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §3.3): create
1108    // the block directly as a new tab of the pane holding
1109    // `stack_onto_block_id`, in one reducer step, then tell the frontend with
1110    // a queued `stackpush` action. Replaces `skip_placement` + a frontend
1111    // push, whose gap could leave the block in no pane.
1112    if let Some(target) = cmd.stack_onto_block_id.clone().filter(|t| !t.is_empty()) {
1113        let tab_id = resolve_tab_id_for_block(&mstore, &target)
1114            .map_err(|e| format!("pane.open: stack_onto_block_id: {e}"))?;
1115        let meta_val = serde_json::to_value(&meta)
1116            .map_err(|e| format!("pane.open: stack_onto_block_id: meta serialize: {e}"))?;
1117        let events = crate::server::service::dispatch_to_reducer(
1118            state,
1119            agentmux_common::ipc::Command::CreateBlockInStack {
1120                tab_id: tab_id.clone(),
1121                target_block_id: target.clone(),
1122                meta: meta_val,
1123                activate: true,
1124            },
1125        )
1126        .await;
1127        if let Some(msg) = events.iter().find_map(|e| match e {
1128            agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1129            _ => None,
1130        }) {
1131            return Err(format!("pane.open: {msg}"));
1132        }
1133        let block_id = events
1134            .iter()
1135            .find_map(|e| match e {
1136                agentmux_common::ipc::Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
1137                _ => None,
1138            })
1139            .ok_or_else(|| "pane.open: CreateBlockInStack emitted no BlockCreated".to_string())?;
1140        for ev in &events {
1141            if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, &mstore) {
1142                tracing::warn!("pane.open: stack_onto_block_id: mstore apply failed: {e}");
1143            }
1144        }
1145        crate::server::service::publish_events(state, &events);
1146        if let Err(e) =
1147            crate::server::service::layout_helpers::queue_target_stack_push(state, &tab_id, &block_id, &target).await
1148        {
1149            tracing::warn!(block_id = %block_id, "pane.open: stack_onto_block_id: stackpush queue failed: {e}");
1150        }
1151        tracing::info!(block_id = %block_id, target = %target, view = %cmd.view, "pane.open: block created as a pane tab");
1152        return Ok(PaneOpenResult {
1153            block_id,
1154            tab_id,
1155            view: cmd.view,
1156            created: true,
1157        });
1158    }
1159
1160    // Skip-placement path (in-pane tabs — SPEC_PANE_TAB_STRIP_AGENT_TERMINAL_2026_07_20.md
1161    // §4.2): create the block through the reducer, same as the docked path
1162    // below, but return immediately — no layout action, no tear_off_block
1163    // saga. The caller attaches it to an existing pane's block-stack instead
1164    // (`pushBlockOntoStack`), so it must never render docked or floating
1165    // first.
1166    if cmd.skip_placement == Some(true) {
1167        let meta_val = serde_json::to_value(&meta)
1168            .map_err(|e| format!("pane.open: skip_placement: meta serialize: {e}"))?;
1169        let create_events = crate::server::service::dispatch_to_reducer(
1170            state,
1171            agentmux_common::ipc::Command::CreateBlock {
1172                tab_id: tab_id.clone(),
1173                meta: meta_val,
1174            },
1175        )
1176        .await;
1177        if let Some(msg) = create_events.iter().find_map(|e| match e {
1178            agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1179            _ => None,
1180        }) {
1181            return Err(format!("pane.open: skip_placement: CreateBlock: {msg}"));
1182        }
1183        let block_id = create_events
1184            .iter()
1185            .find_map(|e| match e {
1186                agentmux_common::ipc::Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
1187                _ => None,
1188            })
1189            .ok_or_else(|| "pane.open: skip_placement: CreateBlock emitted no BlockCreated".to_string())?;
1190        for ev in &create_events {
1191            if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, &mstore) {
1192                tracing::warn!("pane.open: skip_placement: CreateBlock mstore apply failed: {e}");
1193            }
1194        }
1195        crate::server::service::publish_events(state, &create_events);
1196        tracing::info!(block_id = %block_id, view = %cmd.view, "pane.open: block created, placement skipped");
1197        return Ok(PaneOpenResult {
1198            block_id,
1199            tab_id,
1200            view: cmd.view,
1201            created: true,
1202        });
1203    }
1204
1205    // Create block (docked path) THROUGH THE REDUCER (#1681), not wcore-direct.
1206    // A store-only `create_block` lands the block in SQLite but never in the
1207    // reducer-canonical `state.blocks` map — and this RPC runs after bootstrap,
1208    // which is the only time `srv_state` is hydrated from SQLite. The pane then
1209    // renders fine (frontend reads SQLite) but a later TearOffBlock /
1210    // RedockFloatingPane is rejected "block not found" because the saga
1211    // pre-conditions check the reducer. The BlockCreated event carries meta,
1212    // which apply_block_created writes to the mstore Block. Mirrors the
1213    // already-correct open_pane_floating path.
1214    let meta_val = serde_json::to_value(&meta)
1215        .map_err(|e| format!("pane.open: meta serialize: {e}"))?;
1216    let create_events = crate::server::service::dispatch_to_reducer(
1217        state,
1218        agentmux_common::ipc::Command::CreateBlock {
1219            tab_id: tab_id.clone(),
1220            meta: meta_val,
1221        },
1222    )
1223    .await;
1224    if let Some(msg) = create_events.iter().find_map(|e| match e {
1225        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1226        _ => None,
1227    }) {
1228        return Err(format!("pane.open: CreateBlock: {msg}"));
1229    }
1230    let block_id = create_events
1231        .iter()
1232        .find_map(|e| match e {
1233            agentmux_common::ipc::Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
1234            _ => None,
1235        })
1236        .ok_or_else(|| "pane.open: CreateBlock emitted no BlockCreated".to_string())?;
1237    for ev in &create_events {
1238        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, &mstore) {
1239            tracing::warn!("pane.open: CreateBlock mstore apply failed: {e}");
1240        }
1241    }
1242    crate::server::service::publish_events(state, &create_events);
1243
1244    // Enqueue layout action — split if requested, else append
1245    let (actiontype, targetblockid, position) = pane::resolve_placement(
1246        cmd.split_direction.as_deref(),
1247        cmd.split_reference_block_id.as_deref(),
1248    );
1249    let focused = cmd.focus.unwrap_or(true);
1250
1251    // SPEC_864 Phase 4 — append through the reducer (single writer of
1252    // db_layout). Best-effort like the store-direct write it replaces:
1253    // a failure leaves the block created but not laid out.
1254    {
1255        let action = obj::LayoutActionData {
1256            actiontype,
1257            actionid: uuid::Uuid::new_v4().to_string(),
1258            blockid: block_id.clone(),
1259            nodesize: None,
1260            nodesizefraction: None,
1261            indexarr: None,
1262            focused,
1263            magnified: false,
1264            ephemeral: false,
1265            targetblockid,
1266            position,
1267        };
1268        if let Err(e) = crate::server::service::queue_layout_actions_via_reducer(
1269            state,
1270            &tab_id,
1271            vec![action],
1272        )
1273        .await
1274        {
1275            tracing::warn!("pane.open: layout action enqueue failed: {e}");
1276        }
1277    }
1278
1279    tracing::info!(
1280        block_id = %block_id,
1281        view = %cmd.view,
1282        "pane.open: block created + layout updated"
1283    );
1284
1285    // Broadcast block + tab + layout updates
1286    {
1287        let mut updates = Vec::new();
1288        if let Ok(updated_block) = mstore.must_get::<Block>(&block_id) {
1289            updates.push(obj::MuxObjUpdate {
1290                updatetype: "update".into(),
1291                otype: "block".into(),
1292                oid: block_id.clone(),
1293                obj: Some(obj::mux_obj_to_value(&updated_block)),
1294            });
1295        }
1296        if let Ok(updated_tab) = mstore.must_get::<Tab>(&tab_id) {
1297            updates.push(obj::MuxObjUpdate {
1298                updatetype: "update".into(),
1299                otype: "tab".into(),
1300                oid: tab_id.clone(),
1301                obj: Some(obj::mux_obj_to_value(&updated_tab)),
1302            });
1303            if let Ok(updated_layout) = mstore.must_get::<obj::LayoutState>(&updated_tab.layoutstate) {
1304                updates.push(obj::MuxObjUpdate {
1305                    updatetype: "update".into(),
1306                    otype: "layout".into(),
1307                    oid: updated_tab.layoutstate.clone(),
1308                    obj: Some(obj::mux_obj_to_value(&updated_layout)),
1309                });
1310            }
1311        }
1312        // One batched frame so the renderer applies all of them in a single
1313        // reactive flush — see EventBus::broadcast_mux_obj_updates.
1314        event_bus.broadcast_mux_obj_updates(&updates);
1315    }
1316
1317    Ok(PaneOpenResult {
1318        block_id,
1319        tab_id,
1320        view: cmd.view,
1321        created: true,
1322    })
1323}
1324
1325/// Core `pane.moveTab` logic — reorder `cmd.block_id` within its own pane, or
1326/// move it into a different pane (`Command::LayoutStackMove`). Queues a
1327/// `stackmove` layout action so any OTHER window/tab viewing the same
1328/// `db_layout` sees the change (mirrors `stack_onto_block_id`'s `stackpush`
1329/// queue above); the calling frontend applies its own optimistic local edit
1330/// directly and does not wait on the queue for itself.
1331/// SPEC_PANE_TAB_DRAG_AND_DROP_2026_09_19.md §4.1, Phase 3.
1332pub(crate) async fn move_tab(state: &AppState, cmd: CommandPaneMoveTabData) -> Result<(), String> {
1333    let mstore = &state.mstore;
1334    let position = match cmd.position.as_str() {
1335        "before" => agentmux_common::StackMovePosition::Before,
1336        "after" => agentmux_common::StackMovePosition::After,
1337        "end" => agentmux_common::StackMovePosition::End,
1338        other => return Err(format!("pane.moveTab: invalid position '{other}' (expected before/after/end)")),
1339    };
1340    let tab_id = resolve_tab_id_for_block(mstore, &cmd.block_id)
1341        .map_err(|e| format!("pane.moveTab: {e}"))?;
1342    // Cross-pane moves (Phase 4, SPEC_PANE_TAB_DRAG_AND_DROP_2026_09_19.md
1343    // §3.4) were rejected here through Phase 3 (Codex P2 on PR #3444) —
1344    // the frontend's pending-action handler didn't yet mirror a cross-leaf
1345    // "stackmove" to other windows/tabs watching this layout. That gap is
1346    // now closed (layoutPersistence.ts's StackMove case, via
1347    // moveMemberAcrossStacks), so both same-pane and cross-pane targets are
1348    // accepted here — Command::LayoutStackMove already validates everything
1349    // else (existence, non-empty source, etc.) via move_stack_member.
1350    let events = crate::server::service::dispatch_to_reducer(
1351        state,
1352        agentmux_common::ipc::Command::LayoutStackMove {
1353            tab_id: tab_id.clone(),
1354            block_id: cmd.block_id.clone(),
1355            target_block_id: cmd.target_block_id.clone(),
1356            position,
1357            activate: cmd.activate,
1358            correlation_id: String::new(),
1359        },
1360    )
1361    .await;
1362    if let Some(msg) = events.iter().find_map(|e| match e {
1363        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1364        _ => None,
1365    }) {
1366        return Err(format!("pane.moveTab: {msg}"));
1367    }
1368    for ev in &events {
1369        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, mstore) {
1370            tracing::warn!("pane.moveTab: mstore apply failed: {e}");
1371        }
1372    }
1373    crate::server::service::publish_events(state, &events);
1374    if let Err(e) = crate::server::service::layout_helpers::queue_target_stack_move(
1375        state,
1376        &tab_id,
1377        &cmd.block_id,
1378        &cmd.target_block_id,
1379        &cmd.position,
1380        cmd.activate,
1381    )
1382    .await
1383    {
1384        tracing::warn!(block_id = %cmd.block_id, "pane.moveTab: stackmove queue failed: {e}");
1385    }
1386    tracing::info!(block_id = %cmd.block_id, target = %cmd.target_block_id, position = %cmd.position, "pane.moveTab");
1387    Ok(())
1388}