agentmux_srv\server/
app_api.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! App API — high-level commands for programmatic control of AgentMux.
5//!
6//! These commands orchestrate multiple low-level operations (CreateBlock, SetMeta,
7//! ControllerResync) behind stable, intent-based interfaces. Callers express what
8//! they want ("open an agent pane with AgentX"), not how to do it.
9
10use std::sync::Arc;
11
12use base64::Engine;
13use serde_json::json;
14
15use crate::backend::blockcontroller;
16use crate::backend::obj::{self, Block, Tab, Workspace, MetaMapType};
17use crate::backend::providers;
18use crate::backend::rpc::engine::WshRpcEngine;
19use crate::backend::rpc_types::*;
20use crate::backend::session_archive;
21use crate::backend::storage::store::{Store, AgentContent, AgentDefinition, AgentInstance};
22
23use super::AppState;
24use crate::server::cli_handlers::resolve_cli_on_path;
25
26/// Register all App API handlers on the RPC engine.
27pub fn register_app_api_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
28    register_agent_open(engine, state);
29    register_agent_send(engine, state);
30    register_agent_stop(engine, state);
31    register_agent_status(engine, state);
32    register_agent_list(engine, state);
33    register_agent_output(engine, state);
34    register_agent_process_list(engine, state);
35    register_agent_tracked_blocks(engine, state);
36    register_agent_kill_process(engine, state);
37    register_agent_kill_tree(engine, state);
38    register_agent_define(engine, state);
39    register_pane_open(engine, state);
40    register_blockfile_line_count(engine, state);
41    register_blockfile_read_range(engine, state);
42    register_blockfile_read_state(engine, state);
43    register_blockfile_write_state(engine, state);
44    register_session_digest(engine, state);
45    register_session_activity_summary(engine, state);
46    register_session_archive_handler(engine, state);
47    register_session_restore_handler(engine, state);
48    register_session_export_handler(engine, state);
49}
50
51// ---------------------------------------------------------------------------
52// agent.process-list + agent.tracked-blocks
53// ---------------------------------------------------------------------------
54
55fn register_agent_process_list(engine: &Arc<WshRpcEngine>, state: &AppState) {
56    let process_tracker = state.process_tracker.clone();
57    engine.register_handler(
58        COMMAND_AGENT_PROCESS_LIST,
59        Box::new(move |data, _ctx| {
60            let process_tracker = process_tracker.clone();
61            Box::pin(async move {
62                let cmd: AgentProcessListCommand = serde_json::from_value(data)
63                    .map_err(|e| format!("agent.process-list: {e}"))?;
64                let members = process_tracker.list_block(&cmd.block_id);
65                let confidence = match process_tracker.confidence_of(&cmd.block_id) {
66                    crate::backend::process_tracker::TrackingConfidence::High => "high",
67                    crate::backend::process_tracker::TrackingConfidence::BestEffort => "best_effort",
68                    crate::backend::process_tracker::TrackingConfidence::None => "none",
69                };
70                let processes: Vec<AgentProcessInfo> = members
71                    .into_iter()
72                    .map(|m| AgentProcessInfo {
73                        pid: m.pid,
74                        command: m.command,
75                        rss_bytes: m.rss_bytes,
76                        started_at_ms: m.started_at_ms,
77                    })
78                    .collect();
79                Ok(Some(serde_json::to_value(&AgentProcessListResult {
80                    block_id: cmd.block_id,
81                    confidence: confidence.to_string(),
82                    processes,
83                }).unwrap()))
84            })
85        }),
86    );
87}
88
89fn register_agent_tracked_blocks(engine: &Arc<WshRpcEngine>, state: &AppState) {
90    let process_tracker = state.process_tracker.clone();
91    engine.register_handler(
92        COMMAND_AGENT_TRACKED_BLOCKS,
93        Box::new(move |_data, _ctx| {
94            let process_tracker = process_tracker.clone();
95            Box::pin(async move {
96                let process_ids = process_tracker.list_all_blocks();
97                let reactive_ids = crate::backend::reactive::get_global_handler().list_active_blocks();
98                let mut seen = std::collections::HashSet::new();
99                let block_ids: Vec<String> = process_ids.into_iter().chain(reactive_ids)
100                    .filter(|id| seen.insert(id.clone()))
101                    .collect();
102                Ok(Some(serde_json::to_value(&AgentTrackedBlocksResult {
103                    block_ids,
104                }).unwrap()))
105            })
106        }),
107    );
108}
109
110fn register_agent_kill_process(engine: &Arc<WshRpcEngine>, state: &AppState) {
111    let process_tracker = state.process_tracker.clone();
112    engine.register_handler(
113        COMMAND_AGENT_KILL_PROCESS,
114        Box::new(move |data, _ctx| {
115            let process_tracker = process_tracker.clone();
116            Box::pin(async move {
117                let cmd: AgentKillProcessCommand = serde_json::from_value(data)
118                    .map_err(|e| format!("agent.kill-process: {e}"))?;
119                tracing::info!(
120                    block_id = %cmd.block_id,
121                    pid = cmd.pid,
122                    "agent.kill-process"
123                );
124                let ok = process_tracker.kill_pid(&cmd.block_id, cmd.pid);
125                Ok(Some(serde_json::to_value(&AgentKillResult { ok }).unwrap()))
126            })
127        }),
128    );
129}
130
131fn register_agent_kill_tree(engine: &Arc<WshRpcEngine>, state: &AppState) {
132    let process_tracker = state.process_tracker.clone();
133    engine.register_handler(
134        COMMAND_AGENT_KILL_TREE,
135        Box::new(move |data, _ctx| {
136            let process_tracker = process_tracker.clone();
137            Box::pin(async move {
138                let cmd: AgentKillTreeCommand = serde_json::from_value(data)
139                    .map_err(|e| format!("agent.kill-tree: {e}"))?;
140                tracing::info!(block_id = %cmd.block_id, "agent.kill-tree");
141                let ok = process_tracker.kill_tree(&cmd.block_id);
142                Ok(Some(serde_json::to_value(&AgentKillResult { ok }).unwrap()))
143            })
144        }),
145    );
146}
147
148// ---------------------------------------------------------------------------
149// agent.open
150// ---------------------------------------------------------------------------
151
152fn register_agent_open(engine: &Arc<WshRpcEngine>, state: &AppState) {
153    let wstore = state.wstore.clone();
154    let broker = state.broker.clone();
155    let event_bus = state.event_bus.clone();
156    let filestore = state.filestore.clone();
157    // Capture the whole (Arc-backed, Clone) AppState so the block can be created
158    // through the reducer (#1681) — see the create-block site below.
159    let app_state = state.clone();
160
161    engine.register_handler(
162        COMMAND_AGENT_OPEN,
163        Box::new(move |data, _ctx| {
164            let wstore = wstore.clone();
165            let broker = broker.clone();
166            let event_bus = event_bus.clone();
167            let filestore = filestore.clone();
168            let app_state = app_state.clone();
169            Box::pin(async move {
170                let cmd: CommandAgentOpenData = serde_json::from_value(data)
171                    .map_err(|e| format!("agent.open: {e}"))?;
172
173                tracing::info!(agent_id = %cmd.agent_id, "agent.open");
174
175                // 1. Load the agent definition (by id or name)
176                let agents = wstore.agent_def_list()
177                    .map_err(|e| format!("agent.open: {e}"))?;
178                let agent = agents.iter()
179                    .find(|a| a.id == cmd.agent_id || a.name.eq_ignore_ascii_case(&cmd.agent_id))
180                    .ok_or_else(|| format!("AGENT_NOT_FOUND: no agent definition with id '{}'", cmd.agent_id))?
181                    .clone();
182
183                // 2. Resolve provider
184                let provider = providers::get_provider(&agent.provider)
185                    .ok_or_else(|| format!("INVALID_PROVIDER: unknown provider '{}'", agent.provider))?;
186
187                // 3. Determine tab
188                let tab_id = resolve_tab_id(&wstore, cmd.tab_id.as_deref())?;
189
190                // 4. Check for existing agent pane in this tab (idempotent)
191                // Use resolved agent.id (not raw user input which could be a name)
192                if let Some(existing) = find_agent_block(&wstore, &tab_id, &agent.id)? {
193                    // Ensure the controller is registered (may be missing if block
194                    // was created by the frontend without backend initialization)
195                    if blockcontroller::get_controller(&existing.oid).is_none() {
196                        let controller_type = provider.controller_type_str();
197                        // Set essential metadata if missing
198                        let mut meta_update = obj::MetaMapType::new();
199                        meta_update.insert("controller".to_string(), json!(controller_type));
200                        meta_update.insert("agentProvider".to_string(), json!(&agent.provider));
201                        let _ = crate::server::service::update_object_meta(
202                            &wstore, &format!("block:{}", existing.oid), &meta_update,
203                        );
204                        // Register controller
205                        let block_for_resync = wstore.must_get::<Block>(&existing.oid)
206                            .map_err(|e| format!("agent.open: reload block: {e}"))?;
207                        let _ = blockcontroller::resync_controller(
208                            &block_for_resync, &tab_id, None, true,
209                            Some(broker.clone()), Some(event_bus.clone()), Some(wstore.clone()),
210                            Some(filestore.clone()),
211                        );
212                    }
213                    let status = blockcontroller::get_block_controller_status(&existing.oid)
214                        .map(|s| s.shellprocstatus)
215                        .unwrap_or_else(|| "init".to_string());
216                    return Ok(Some(serde_json::to_value(&AgentOpenResult {
217                        block_id: existing.oid,
218                        tab_id,
219                        agent_id: cmd.agent_id,
220                        provider: agent.provider,
221                        controller_type: provider.controller_type_str().to_string(),
222                        status,
223                        created: false,
224                    }).unwrap()));
225                }
226
227                // 5. Resolve CLI path
228                let version = env!("CARGO_PKG_VERSION");
229                let home = std::env::var("HOME")
230                    .or_else(|_| std::env::var("USERPROFILE"))
231                    .map_err(|_| "cannot determine home directory".to_string())?;
232                let provider_dir = format!("{}/.agentmux/{}/cli/{}", home, version, provider.id);
233                let npm_bin = if cfg!(windows) {
234                    format!("{}/node_modules/.bin/{}.cmd", provider_dir, provider.cli_command)
235                } else {
236                    format!("{}/node_modules/.bin/{}", provider_dir, provider.cli_command)
237                };
238                let mut resolved_cli_path = npm_bin.clone();
239                if !std::path::Path::new(&resolved_cli_path).exists() {
240                    // Fallback: provider not installed via npm — try system PATH.
241                    // This is used for Python-based CLIs like Kimi that are not
242                    // distributed on npm.
243                    if provider.npm_package.is_empty() {
244                        if let Some(path) = resolve_cli_on_path(provider.cli_command).await {
245                            resolved_cli_path = path;
246                        }
247                    }
248                    if !std::path::Path::new(&resolved_cli_path).exists() {
249                        return Err(format!(
250                            "CLI_NOT_AVAILABLE: {} not installed at {}. Open an agent pane in the UI to trigger installation.",
251                            provider.cli_command, npm_bin
252                        ));
253                    }
254                }
255
256                // 6. Build metadata
257                let controller_type = provider.controller_type_str();
258                let is_persistent = controller_type == "persistent";
259                let mut cli_args: Vec<String> = if is_persistent {
260                    provider.persistent_launch_args
261                        .unwrap_or(provider.launch_args)
262                        .iter().map(|s| s.to_string()).collect()
263                } else {
264                    provider.launch_args.iter().map(|s| s.to_string()).collect()
265                };
266                // Append definition-level flags (e.g. --model <value>) stored in provider_flags.
267                if !agent.provider_flags.is_empty() {
268                    cli_args.extend(agent.provider_flags.split_whitespace().map(str::to_string));
269                }
270
271                let agent_slug = agent.name.to_lowercase()
272                    .chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
273                    .collect::<String>();
274                let work_dir = if agent.working_directory.is_empty() {
275                    format!("~/.agentmux/agents/{}", agent_slug)
276                } else {
277                    agent.working_directory.clone()
278                };
279
280                // Build env vars
281                let mut env_vars = serde_json::Map::new();
282                for key in provider.unset_env {
283                    env_vars.insert(key.to_string(), json!(""));
284                }
285                // Use AGENTMUX_CONFIG_HOME so portable installs stay self-contained.
286                // Falls back to ~/.agentmux/config for non-portable installs.
287                let config_home = std::env::var("AGENTMUX_CONFIG_HOME")
288                    .unwrap_or_else(|_| format!("{}/.agentmux/config", home));
289                // Auth dir — the DEFAULT provider auth lives in the shared,
290                // instance/channel/version-independent providers area so a single
291                // login is shared everywhere (the structural fix for the per-channel
292                // validate-spin regression). The per-identity bundle override
293                // (identity_handlers) still wins for explicit multi-account.
294                let auth_dir = agentmux_common::DataPaths::from_env()
295                    .map(|p| p.provider_auth_dir(provider.auth_dir_name).to_string_lossy().into_owned())
296                    .unwrap_or_else(|| format!("{}/.agentmux/shared/providers/{}", home, provider.auth_dir_name));
297                let _ = std::fs::create_dir_all(&auth_dir);
298                env_vars.insert(provider.auth_config_dir_env_var.to_string(), json!(auth_dir));
299                for (k, v) in provider.auth_extra_env {
300                    env_vars.insert(k.to_string(), json!(v));
301                }
302                // Merge env vars from the definition's persisted env content blob (KEY=VALUE lines).
303                // Provider/auth entries inserted above take precedence; definition-level vars
304                // are merged after so they can extend (but not override) the auth env.
305                if let Ok(Some(env_blob)) = wstore.agent_content_get(&agent.id, "env") {
306                    for line in env_blob.content.lines() {
307                        if let Some((k, v)) = line.split_once('=') {
308                            let k = k.trim();
309                            if !k.is_empty() && !env_vars.contains_key(k) {
310                                env_vars.insert(k.to_string(), json!(v));
311                            }
312                        }
313                    }
314                }
315                // Agent identity
316                env_vars.insert("GH_CONFIG_DIR".to_string(), json!(format!("{}/gh-{}", config_home, agent_slug)));
317                // Use stored slug (stable across renames) for muxbus routing;
318                // fall back to the computed slug derived from the display name.
319                let routing_id = if !agent.slug.is_empty() { &agent.slug } else { &agent_slug };
320                env_vars.insert("AGENTMUX_AGENT_ID".to_string(), json!(routing_id));
321                // Exit delay only for subprocess
322                if !is_persistent {
323                    env_vars.insert("CLAUDE_CODE_EXIT_AFTER_STOP_DELAY".to_string(), json!("30000"));
324                }
325
326                let mut meta = MetaMapType::new();
327                meta.insert("view".to_string(), json!("agent"));
328                meta.insert("agentId".to_string(), json!(&agent.id));
329                // Per-agent zoom persistence (SPEC_AGENT_ZOOM_PERSISTENCE): seed
330                // the new block's `term:zoom` from the agent's saved `ui:zoom`
331                // (per-agent content store, global cross-channel) so reopening
332                // the same agent restores its zoom instead of resetting to 1.0.
333                // Stored only for non-default zooms; clamp to the frontend's
334                // [0.5, 2.0] range so a corrupt value can't escape it.
335                if let Ok(Some(c)) = wstore.agent_content_get(&agent.id, "ui:zoom") {
336                    if let Some(z) = parse_seed_zoom(&c.content) {
337                        meta.insert("term:zoom".to_string(), json!(z));
338                    }
339                }
340                meta.insert("agentProvider".to_string(), json!(&agent.provider));
341                meta.insert("agentName".to_string(), json!(&agent.name));
342                meta.insert("agentIcon".to_string(), json!(if agent.icon.is_empty() { "sparkles" } else { &agent.icon }));
343                meta.insert("agentMode".to_string(), json!(if agent.agent_type.is_empty() { "host" } else { &agent.agent_type }));
344                if !agent.container_image.is_empty() {
345                    meta.insert("agent:container_image".to_string(), json!(&agent.container_image));
346                }
347                if agent.container_volumes != "[]" && !agent.container_volumes.is_empty() {
348                    meta.insert("agent:container_volumes".to_string(), json!(&agent.container_volumes));
349                }
350                // Container-local CLI command: the provider's CLI as it resolves
351                // INSIDE the image (on the image's PATH, e.g. `claude`). Distinct
352                // from `cmd` below, which is the host-resolved absolute npm path
353                // (`<config_home>/.../node_modules/.bin/claude`) and does NOT exist
354                // in the container — passing it as docker-exec argv[0] would fail
355                // with "no such file or directory". Container turns use this.
356                meta.insert("agent:container_command".to_string(), json!(provider.cli_command));
357                // Derive output format from provider ID (matches frontend providers/index.ts)
358                let output_format = match provider.id {
359                    "claude" => "claude-stream-json",
360                    "codex" => "codex-json",
361                    "gemini" => "gemini-json",
362                    // Qwen Code is a Gemini-CLI fork → same stream-json schema.
363                    "qwen" => "gemini-json",
364                    "kimi" => "kimi-stream-json",
365                    _ => "claude-stream-json",
366                };
367                meta.insert("agentOutputFormat".to_string(), json!(output_format));
368                meta.insert("controller".to_string(), json!(controller_type));
369                meta.insert("cmd".to_string(), json!(&resolved_cli_path));
370                meta.insert("cmd:args".to_string(), json!(cli_args));
371                meta.insert("cmd:cwd".to_string(), json!(&work_dir));
372                meta.insert("cmd:env".to_string(), serde_json::Value::Object(env_vars));
373                meta.insert("agent:resume_flag".to_string(), json!(provider.resume_flag.unwrap_or("")));
374                meta.insert("agent:session_id_field".to_string(), json!(provider.session_id_field));
375
376                // 7. Create block + insert into layout tree.
377                // Through the reducer (#1681), not wcore-direct: a store-only
378                // block is invisible to the reducer-canonical `state.blocks`
379                // (only hydrated from SQLite at bootstrap), so tearing this agent
380                // pane off later was rejected "block not found". BlockCreated
381                // carries meta → apply_block_created writes the wstore Block,
382                // which the controller resync below reloads by id.
383                let meta_val = serde_json::to_value(&meta)
384                    .map_err(|e| format!("agent.open: meta serialize: {e}"))?;
385                let create_events = crate::server::service::dispatch_to_reducer(
386                    &app_state,
387                    agentmux_common::ipc::Command::CreateBlock {
388                        tab_id: tab_id.clone(),
389                        meta: meta_val,
390                    },
391                )
392                .await;
393                if let Some(msg) = create_events.iter().find_map(|e| match e {
394                    agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
395                    _ => None,
396                }) {
397                    return Err(format!("agent.open: CreateBlock: {msg}"));
398                }
399                let block_id = create_events
400                    .iter()
401                    .find_map(|e| match e {
402                        agentmux_common::ipc::Event::BlockCreated { block_id, .. } => {
403                            Some(block_id.clone())
404                        }
405                        _ => None,
406                    })
407                    .ok_or_else(|| "agent.open: CreateBlock emitted no BlockCreated".to_string())?;
408                for ev in &create_events {
409                    if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, &wstore) {
410                        tracing::warn!("agent.open: CreateBlock wstore apply failed: {e}");
411                    }
412                }
413                crate::server::service::publish_events(&app_state, &create_events);
414
415                // Enqueue a layout insert action for the frontend to process.
416                // The frontend's LayoutModel watches pendingbackendactions on the
417                // LayoutState and applies them via treeReducer — same mechanism
418                // used by cross-window drag-and-drop (dnd.rs).
419                {
420                    let tab: Tab = wstore.must_get(&tab_id)
421                        .map_err(|e| format!("agent.open: reload tab: {e}"))?;
422                    if let Ok(mut layout) = wstore.must_get::<obj::LayoutState>(&tab.layoutstate) {
423                        let mut actions = layout.pendingbackendactions.take().unwrap_or_default();
424                        actions.push(obj::LayoutActionData {
425                            actiontype: "insert".to_string(),
426                            actionid: uuid::Uuid::new_v4().to_string(),
427                            blockid: block_id.clone(),
428                            nodesize: None,
429                            indexarr: None,
430                            focused: true,
431                            magnified: false,
432                            ephemeral: false,
433                            targetblockid: String::new(),
434                            position: String::new(),
435                        });
436                        layout.pendingbackendactions = Some(actions);
437                        let _ = wstore.update(&mut layout);
438                    }
439                }
440
441                tracing::info!(
442                    block_id = %block_id,
443                    agent_id = %cmd.agent_id,
444                    provider = %agent.provider,
445                    controller_type = %controller_type,
446                    "agent.open: block created + layout updated"
447                );
448
449                // 8. Write agent config files. No collision resolution
450                //    in this path — the function creates the dir if
451                //    missing and overwrites whatever's there. Same-
452                //    name same-hour launches will share a workdir;
453                //    proper allocation is tracked as a follow-up.
454                write_agent_config_files(&wstore, &agent, routing_id, &work_dir)?;
455
456                // 9. Register controller (resync)
457                let block_for_resync = wstore.must_get::<Block>(&block_id)
458                    .map_err(|e| format!("agent.open: reload block: {e}"))?;
459                blockcontroller::resync_controller(
460                    &block_for_resync,
461                    &tab_id,
462                    None,
463                    true,
464                    Some(broker.clone()),
465                    Some(event_bus.clone()),
466                    Some(wstore.clone()),
467                    Some(filestore.clone()),
468                )?;
469
470                // 10. Broadcast block + tab + layout updates to frontend
471                {
472                    let mut updates = Vec::new();
473                    if let Ok(updated_block) = wstore.must_get::<Block>(&block_id) {
474                        updates.push(obj::WaveObjUpdate {
475                            updatetype: "update".into(),
476                            otype: "block".into(),
477                            oid: block_id.clone(),
478                            obj: Some(obj::wave_obj_to_value(&updated_block)),
479                        });
480                    }
481                    if let Ok(updated_tab) = wstore.must_get::<Tab>(&tab_id) {
482                        updates.push(obj::WaveObjUpdate {
483                            updatetype: "update".into(),
484                            otype: "tab".into(),
485                            oid: tab_id.clone(),
486                            obj: Some(obj::wave_obj_to_value(&updated_tab)),
487                        });
488                        if let Ok(updated_layout) = wstore.must_get::<obj::LayoutState>(&updated_tab.layoutstate) {
489                            updates.push(obj::WaveObjUpdate {
490                                updatetype: "update".into(),
491                                otype: "layout".into(),
492                                oid: updated_tab.layoutstate.clone(),
493                                obj: Some(obj::wave_obj_to_value(&updated_layout)),
494                            });
495                        }
496                    }
497                    for update in &updates {
498                        let oref = format!("{}:{}", update.otype, update.oid);
499                        if let Ok(data) = serde_json::to_value(update) {
500                            event_bus.broadcast_event(
501                                &crate::backend::eventbus::WSEventType {
502                                    eventtype: "waveobj:update".to_string(),
503                                    oref,
504                                    data: Some(data),
505                                },
506                            );
507                        }
508                    }
509                }
510
511                Ok(Some(serde_json::to_value(&AgentOpenResult {
512                    block_id,
513                    tab_id,
514                    agent_id: cmd.agent_id,
515                    provider: agent.provider,
516                    controller_type: controller_type.to_string(),
517                    status: "init".to_string(),
518                    created: true,
519                }).unwrap()))
520            })
521        }),
522    );
523}
524
525// ---------------------------------------------------------------------------
526// agent.send
527// ---------------------------------------------------------------------------
528
529fn register_agent_send(engine: &Arc<WshRpcEngine>, state: &AppState) {
530    let wstore = state.wstore.clone();
531    let broker = state.broker.clone();
532    let container_manager = state.container_manager.clone();
533
534    engine.register_handler(
535        COMMAND_AGENT_SEND,
536        Box::new(move |data, _ctx| {
537            let wstore = wstore.clone();
538            let broker = broker.clone();
539            let container_manager = container_manager.clone();
540            Box::pin(async move {
541                let cmd: CommandAgentSendData = serde_json::from_value(data)
542                    .map_err(|e| format!("agent.send: {e}"))?;
543
544                tracing::info!(block_id = %cmd.block_id, "agent.send");
545
546                let ctrl = blockcontroller::get_controller(&cmd.block_id)
547                    .ok_or_else(|| format!("NOT_RUNNING: no controller for block {}", cmd.block_id))?;
548
549                // Re-read spawn config from block metadata (same pattern as agentinput)
550                let block: Block = wstore
551                    .get(&cmd.block_id)
552                    .map_err(|e| format!("agent.send: {e}"))?
553                    .ok_or_else(|| format!("BLOCK_NOT_FOUND: {}", cmd.block_id))?;
554
555                let cli_command = obj::meta_get_string(&block.meta, "cmd", "claude");
556                let cli_args: Vec<String> = match block.meta.get("cmd:args") {
557                    Some(serde_json::Value::Array(arr)) => arr
558                        .iter()
559                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
560                        .collect(),
561                    _ => vec![],
562                };
563                let working_dir = obj::meta_get_string(&block.meta, "cmd:cwd", "");
564                let mut env_vars: std::collections::HashMap<String, String> = match block.meta.get("cmd:env") {
565                    Some(serde_json::Value::Object(obj)) => obj
566                        .iter()
567                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
568                        .collect(),
569                    _ => std::collections::HashMap::new(),
570                };
571                // Identity injection — same path as websocket.rs's
572                // AgentInputCommand. See identity/resolver.rs. Passes
573                // the broker so the OAuth-class branch can publish a
574                // `identitybundlebindings:changed:<bundle_id>` event
575                // when the expiry probe updates an account's status
576                // (PR D — spec §4.4).
577                env_vars = crate::identity::resolver::inject_identity_env_async(
578                    wstore.clone(),
579                    Some(broker.clone()),
580                    cmd.block_id.clone(),
581                    env_vars,
582                )
583                .await;
584                let session_id_field = obj::meta_get_string(
585                    &block.meta, "agent:session_id_field", "session_id",
586                );
587
588                // Dispatch to persistent or subprocess controller
589                let mut session_id = None;
590                if let Some(persistent_ctrl) = ctrl
591                    .as_any()
592                    .downcast_ref::<blockcontroller::persistent::PersistentSubprocessController>()
593                {
594                    // Container agents use per-turn docker exec — incompatible with a
595                    // long-lived persistent subprocess. Fail loudly instead of silently
596                    // spawning the CLI on the host.
597                    let agent_mode = obj::meta_get_string(&block.meta, "agentMode", "host");
598                    if agent_mode == "container" {
599                        return Err("container agents require a subprocess controller; this provider uses a persistent controller".to_string());
600                    }
601                    // Resume parity with the subprocess path: pass the resume
602                    // flag + captured session id so a respawn (e.g. after a
603                    // /model change) continues the same conversation.
604                    let resume_flag = obj::meta_get_string(
605                        &block.meta, "agent:resume_flag", "--resume",
606                    );
607                    let persisted_session_id = obj::meta_get_string(
608                        &block.meta, "agent:sessionid", "",
609                    );
610                    let config = blockcontroller::persistent::PersistentSpawnConfig {
611                        cli_command,
612                        cli_args,
613                        working_dir,
614                        env_vars,
615                        session_id_field,
616                        resume_flag,
617                        session_id: persisted_session_id,
618                        message_id: None,
619                    };
620                    persistent_ctrl.send_message(cmd.message, config)?;
621                    session_id = persistent_ctrl.session_id();
622                } else if let Some(subprocess_ctrl) = ctrl
623                    .as_any()
624                    .downcast_ref::<blockcontroller::subprocess::SubprocessController>()
625                {
626                    let resume_flag = obj::meta_get_string(
627                        &block.meta, "agent:resume_flag", "--resume",
628                    );
629                    // Picker reattach (parallel of the websocket-path
630                    // logic): hydrate the persisted session id from
631                    // block meta so spawn_turn appends --resume <sid>
632                    // on the FIRST turn after reattach.
633                    let persisted_session_id = obj::meta_get_string(
634                        &block.meta, "agent:sessionid", "",
635                    );
636
637                    // Container agent branch: use Docker socket API exec (P1a: no
638                    // secrets in argv). Host agent branch: regular CLI subprocess.
639                    let agent_mode = obj::meta_get_string(&block.meta, "agentMode", "host");
640                    if agent_mode == "container" {
641                        let cm = container_manager.as_deref()
642                            .ok_or_else(|| "Docker not available on this host; cannot start container agent".to_string())?;
643                        let container_image = obj::meta_get_string(
644                            &block.meta, "agent:container_image", "ghcr.io/agentmuxai/agent-claude:latest",
645                        );
646                        // Use agentId (UUID) — always valid as a Docker name; display names can have spaces.
647                        let agent_id = obj::meta_get_string(&block.meta, "agentId", "");
648                        let container_name = crate::backend::container::container_name_for_slug(&agent_id);
649                        let volumes_json = obj::meta_get_string(&block.meta, "agent:container_volumes", "[]");
650                        let volumes: Vec<String> = serde_json::from_str(&volumes_json).unwrap_or_default();
651
652                        // Ensure container is alive (pull image if needed — P1b).
653                        cm.ensure_running(&container_name, &container_image, &volumes, &[]).await
654                            .map_err(|e| format!("container ensure_running failed: {e}"))?;
655
656
657                        tracing::info!(
658                            container = %container_name,
659                            image = %container_image,
660                            "container agent turn: bollard exec (env via Docker socket, not argv)",
661                        );
662
663                        // Env is passed via CreateExecOptions.env (Docker socket API),
664                        // NOT as -e KEY=VALUE argv args — this prevents CWE-214 exposure.
665                        // spawn_container_turn filters config.env_vars (denylist) per
666                        // turn, so cmd:cwd (host path) and host-path vars never reach
667                        // the container, and each queued turn uses its own env.
668
669                        // Base cmd: [container_command, ...cli_args]. The command
670                        // is the provider CLI resolved INSIDE the image (on PATH,
671                        // e.g. `claude`) — NOT `cli_command`/`cmd`, which is the
672                        // host-resolved absolute npm path and does not exist in the
673                        // container (docker exec would fail "no such file or
674                        // directory"). cli_args are format flags + provider flags —
675                        // no host paths, safe as-is. spawn_container_turn appends
676                        // --resume <sid> internally.
677                        let container_command = obj::meta_get_string(
678                            &block.meta, "agent:container_command", "claude",
679                        );
680                        let mut base_cmd = vec![container_command];
681                        base_cmd.extend(cli_args);
682
683                        let config = blockcontroller::subprocess::SubprocessSpawnConfig {
684                            cli_command: String::new(), // unused by spawn_container_turn
685                            cli_args: vec![],           // unused by spawn_container_turn
686                            working_dir: String::new(), // unused — container has own cwd
687                            env_vars,
688                            message: cmd.message,
689                            resume_flag,
690                            session_id_field,
691                            message_id: None,
692                            session_id: if persisted_session_id.is_empty() {
693                                None
694                            } else {
695                                Some(persisted_session_id)
696                            },
697                        };
698                        subprocess_ctrl.spawn_container_turn(cm.clone(), container_name, base_cmd, config)?;
699                    } else {
700                        // Host agent: regular CLI subprocess (env set on child process, not in argv).
701                        let config = blockcontroller::subprocess::SubprocessSpawnConfig {
702                            cli_command,
703                            cli_args,
704                            working_dir,
705                            env_vars,
706                            message: cmd.message,
707                            resume_flag,
708                            session_id_field,
709                            message_id: None,
710                            session_id: if persisted_session_id.is_empty() {
711                                None
712                            } else {
713                                Some(persisted_session_id)
714                            },
715                        };
716                        subprocess_ctrl.spawn_turn(config)?;
717                    }
718                } else {
719                    return Err("NOT_RUNNING: controller type not supported".to_string());
720                }
721
722                Ok(Some(serde_json::to_value(&AgentSendResult {
723                    block_id: cmd.block_id,
724                    status: "running".to_string(),
725                    session_id,
726                }).unwrap()))
727            })
728        }),
729    );
730}
731
732// ---------------------------------------------------------------------------
733// agent.stop
734// ---------------------------------------------------------------------------
735
736fn register_agent_stop(engine: &Arc<WshRpcEngine>, _state: &AppState) {
737    engine.register_handler(
738        COMMAND_AGENT_STOP_API,
739        Box::new(|data, _ctx| {
740            Box::pin(async move {
741                let cmd: CommandAgentStopApiData = serde_json::from_value(data)
742                    .map_err(|e| format!("agent.stop: {e}"))?;
743
744                tracing::info!(block_id = %cmd.block_id, signal = ?cmd.signal, "agent.stop");
745
746                let ctrl = blockcontroller::get_controller(&cmd.block_id)
747                    .ok_or_else(|| format!("NOT_RUNNING: no controller for block {}", cmd.block_id))?;
748
749                let force = matches!(cmd.signal.as_deref(), Some("SIGKILL") | Some("SIGTERM"));
750                ctrl.stop(!force, blockcontroller::STATUS_DONE)?;
751
752                let exit_code = blockcontroller::get_block_controller_status(&cmd.block_id)
753                    .map(|s| s.shellprocexitcode);
754
755                Ok(Some(serde_json::to_value(&AgentStopResult {
756                    block_id: cmd.block_id,
757                    status: "done".to_string(),
758                    exit_code,
759                }).unwrap()))
760            })
761        }),
762    );
763}
764
765// ---------------------------------------------------------------------------
766// agent.status
767// ---------------------------------------------------------------------------
768
769fn register_agent_status(engine: &Arc<WshRpcEngine>, state: &AppState) {
770    let wstore = state.wstore.clone();
771
772    engine.register_handler(
773        COMMAND_AGENT_STATUS,
774        Box::new(move |data, _ctx| {
775            let wstore = wstore.clone();
776            Box::pin(async move {
777                let cmd: CommandAgentStatusData = serde_json::from_value(data)
778                    .map_err(|e| format!("agent.status: {e}"))?;
779
780                let block: Block = wstore
781                    .get(&cmd.block_id)
782                    .map_err(|e| format!("agent.status: {e}"))?
783                    .ok_or_else(|| format!("BLOCK_NOT_FOUND: {}", cmd.block_id))?;
784
785                let agent_id = obj::meta_get_string(&block.meta, "agentId", "");
786                let provider = obj::meta_get_string(&block.meta, "agentProvider", "");
787                let controller_type = obj::meta_get_string(&block.meta, "controller", "");
788
789                let runtime = blockcontroller::get_block_controller_status(&cmd.block_id);
790                let status = runtime.as_ref()
791                    .map(|s| s.shellprocstatus.clone())
792                    .unwrap_or_else(|| "init".to_string());
793                let exit_code = runtime.as_ref()
794                    .and_then(|s| if s.shellprocstatus == "done" { Some(s.shellprocexitcode) } else { None });
795                let pid = None; // PID not currently exposed in status struct
796
797                // Get session ID from block meta
798                let session_id = block.meta.get("agent:sessionid")
799                    .and_then(|v| v.as_str())
800                    .map(|s| s.to_string());
801
802                Ok(Some(serde_json::to_value(&AgentStatusResult {
803                    block_id: cmd.block_id,
804                    agent_id,
805                    provider,
806                    controller_type,
807                    status,
808                    session_id,
809                    pid,
810                    exit_code,
811                }).unwrap()))
812            })
813        }),
814    );
815}
816
817// ---------------------------------------------------------------------------
818// agent.list
819// ---------------------------------------------------------------------------
820
821fn register_agent_list(engine: &Arc<WshRpcEngine>, state: &AppState) {
822    let wstore = state.wstore.clone();
823
824    engine.register_handler(
825        COMMAND_AGENT_LIST,
826        Box::new(move |_data, _ctx| {
827            let wstore = wstore.clone();
828            Box::pin(async move {
829                let tabs: Vec<Tab> = wstore.get_all::<Tab>()
830                    .map_err(|e| format!("agent.list: {e}"))?;
831
832                let mut agents = Vec::new();
833                for tab in &tabs {
834                    for block_id in &tab.blockids {
835                        if let Ok(Some(block)) = wstore.get::<Block>(block_id) {
836                            let agent_id = obj::meta_get_string(&block.meta, "agentId", "");
837                            if agent_id.is_empty() {
838                                continue;
839                            }
840                            let provider = obj::meta_get_string(&block.meta, "agentProvider", "");
841                            let status = blockcontroller::get_block_controller_status(block_id)
842                                .map(|s| s.shellprocstatus)
843                                .unwrap_or_else(|| "init".to_string());
844                            let session_id = block.meta.get("agent:sessionid")
845                                .and_then(|v| v.as_str())
846                                .map(|s| s.to_string());
847
848                            agents.push(AgentListEntry {
849                                block_id: block_id.clone(),
850                                tab_id: tab.oid.clone(),
851                                agent_id,
852                                provider,
853                                status,
854                                session_id,
855                            });
856                        }
857                    }
858                }
859
860                Ok(Some(serde_json::to_value(&AgentListResult { agents }).unwrap()))
861            })
862        }),
863    );
864}
865
866// ---------------------------------------------------------------------------
867// agent.output
868// ---------------------------------------------------------------------------
869
870fn register_agent_output(engine: &Arc<WshRpcEngine>, state: &AppState) {
871    let broker = state.broker.clone();
872
873    engine.register_handler(
874        COMMAND_AGENT_OUTPUT,
875        Box::new(move |data, _ctx| {
876            let broker = broker.clone();
877            Box::pin(async move {
878                let cmd: CommandAgentOutputData = serde_json::from_value(data)
879                    .map_err(|e| format!("agent.output: {e}"))?;
880
881                let scope = format!("block:{}", cmd.block_id);
882                let max = cmd.max_lines.unwrap_or(1000);
883                let after = cmd.after_line.unwrap_or(0);
884
885                // Read persisted blockfile events from broker history
886                let mut all_lines: Vec<String> = Vec::new();
887                {
888                    let events = broker.read_event_history(
889                        crate::backend::wps::EVENT_BLOCK_FILE,
890                        &scope,
891                        max + after, // read enough to cover offset
892                    );
893                    for event in events {
894                        if let Some(ref data) = event.data {
895                            if let Some(data64) = data.get("data64").and_then(|v| v.as_str()) {
896                                if let Ok(bytes) = base64::engine::general_purpose::STANDARD
897                                    .decode(data64)
898                                {
899                                    let text = String::from_utf8_lossy(&bytes);
900                                    for line in text.lines() {
901                                        if !line.trim().is_empty() {
902                                            all_lines.push(line.to_string());
903                                        }
904                                    }
905                                }
906                            }
907                        }
908                    }
909                }
910
911                let total = all_lines.len();
912                let lines: Vec<String> = all_lines.into_iter()
913                    .skip(after)
914                    .take(max)
915                    .collect();
916                let has_more = after + lines.len() < total;
917
918                Ok(Some(serde_json::to_value(&AgentOutputResult {
919                    block_id: cmd.block_id,
920                    lines,
921                    total_lines: total,
922                    has_more,
923                }).unwrap()))
924            })
925        }),
926    );
927}
928
929// ---------------------------------------------------------------------------
930// pane.open
931// ---------------------------------------------------------------------------
932
933fn register_pane_open(engine: &Arc<WshRpcEngine>, state: &AppState) {
934    let state = state.clone();
935    engine.register_handler(
936        COMMAND_PANE_OPEN,
937        Box::new(move |data, _ctx| {
938            let state = state.clone();
939            Box::pin(async move {
940                let cmd: CommandPaneOpenData = serde_json::from_value(data)
941                    .map_err(|e| format!("pane.open: {e}"))?;
942                let result = open_pane(&state, cmd).await?;
943                Ok(Some(serde_json::to_value(&result).unwrap()))
944            })
945        }),
946    );
947}
948
949/// Core `pane.open` logic, shared by the WebSocket RPC handler
950/// (`register_pane_open`) and the HTTP route `POST /api/v1/pane/open`
951/// (`agentmux-mcp`'s `OpenEditor` tool). Creates a block for the requested
952/// view, enqueues a layout action (split or insert), and broadcasts the
953/// block/tab/layout updates so the frontend renders the new pane.
954pub async fn open_pane(state: &AppState, cmd: CommandPaneOpenData) -> Result<PaneOpenResult, String> {
955    let wstore = state.wstore.clone();
956    let event_bus = state.event_bus.clone();
957
958    tracing::info!(view = %cmd.view, "pane.open");
959
960    // Build meta for the requested view, validating required args
961    let meta = build_pane_meta(&cmd)?;
962
963    // Resolve tab
964    let tab_id = resolve_tab_id(&wstore, cmd.tab_id.as_deref())?;
965
966    // Floating path (SPEC_OPENEDITOR_FLOATING_AND_COLLAPSED_TREE_2026_06_16):
967    // create the block in a fresh floating workspace (via reducer CreateBlock +
968    // the existing tear_off_block saga) and signal the source window's frontend
969    // to materialize the chromeless OS window — srv can't open windows itself.
970    if cmd.floating == Some(true) {
971        return open_pane_floating(state, &wstore, &event_bus, cmd.view, tab_id, meta).await;
972    }
973
974    // Create block (docked path) THROUGH THE REDUCER (#1681), not wcore-direct.
975    // A store-only `create_block` lands the block in SQLite but never in the
976    // reducer-canonical `state.blocks` map — and this RPC runs after bootstrap,
977    // which is the only time `srv_state` is hydrated from SQLite. The pane then
978    // renders fine (frontend reads SQLite) but a later TearOffBlock /
979    // RedockFloatingPane is rejected "block not found" because the saga
980    // pre-conditions check the reducer. The BlockCreated event carries meta,
981    // which apply_block_created writes to the wstore Block. Mirrors the
982    // already-correct open_pane_floating path.
983    let meta_val = serde_json::to_value(&meta)
984        .map_err(|e| format!("pane.open: meta serialize: {e}"))?;
985    let create_events = crate::server::service::dispatch_to_reducer(
986        state,
987        agentmux_common::ipc::Command::CreateBlock {
988            tab_id: tab_id.clone(),
989            meta: meta_val,
990        },
991    )
992    .await;
993    if let Some(msg) = create_events.iter().find_map(|e| match e {
994        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
995        _ => None,
996    }) {
997        return Err(format!("pane.open: CreateBlock: {msg}"));
998    }
999    let block_id = create_events
1000        .iter()
1001        .find_map(|e| match e {
1002            agentmux_common::ipc::Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
1003            _ => None,
1004        })
1005        .ok_or_else(|| "pane.open: CreateBlock emitted no BlockCreated".to_string())?;
1006    for ev in &create_events {
1007        if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, &wstore) {
1008            tracing::warn!("pane.open: CreateBlock wstore apply failed: {e}");
1009        }
1010    }
1011    crate::server::service::publish_events(state, &create_events);
1012
1013    // Enqueue layout action — split if requested, else append
1014    let (actiontype, targetblockid, position) = resolve_placement(
1015        cmd.split_direction.as_deref(),
1016        cmd.split_reference_block_id.as_deref(),
1017    );
1018    let focused = cmd.focus.unwrap_or(true);
1019
1020    {
1021        let tab: Tab = wstore.must_get(&tab_id)
1022            .map_err(|e| format!("pane.open: reload tab: {e}"))?;
1023        if let Ok(mut layout) = wstore.must_get::<obj::LayoutState>(&tab.layoutstate) {
1024            let mut actions = layout.pendingbackendactions.take().unwrap_or_default();
1025            actions.push(obj::LayoutActionData {
1026                actiontype,
1027                actionid: uuid::Uuid::new_v4().to_string(),
1028                blockid: block_id.clone(),
1029                nodesize: None,
1030                indexarr: None,
1031                focused,
1032                magnified: false,
1033                ephemeral: false,
1034                targetblockid,
1035                position,
1036            });
1037            layout.pendingbackendactions = Some(actions);
1038            let _ = wstore.update(&mut layout);
1039        }
1040    }
1041
1042    tracing::info!(
1043        block_id = %block_id,
1044        view = %cmd.view,
1045        "pane.open: block created + layout updated"
1046    );
1047
1048    // Broadcast block + tab + layout updates
1049    {
1050        let mut updates = Vec::new();
1051        if let Ok(updated_block) = wstore.must_get::<Block>(&block_id) {
1052            updates.push(obj::WaveObjUpdate {
1053                updatetype: "update".into(),
1054                otype: "block".into(),
1055                oid: block_id.clone(),
1056                obj: Some(obj::wave_obj_to_value(&updated_block)),
1057            });
1058        }
1059        if let Ok(updated_tab) = wstore.must_get::<Tab>(&tab_id) {
1060            updates.push(obj::WaveObjUpdate {
1061                updatetype: "update".into(),
1062                otype: "tab".into(),
1063                oid: tab_id.clone(),
1064                obj: Some(obj::wave_obj_to_value(&updated_tab)),
1065            });
1066            if let Ok(updated_layout) = wstore.must_get::<obj::LayoutState>(&updated_tab.layoutstate) {
1067                updates.push(obj::WaveObjUpdate {
1068                    updatetype: "update".into(),
1069                    otype: "layout".into(),
1070                    oid: updated_tab.layoutstate.clone(),
1071                    obj: Some(obj::wave_obj_to_value(&updated_layout)),
1072                });
1073            }
1074        }
1075        for update in &updates {
1076            let oref = format!("{}:{}", update.otype, update.oid);
1077            if let Ok(data) = serde_json::to_value(update) {
1078                event_bus.broadcast_event(
1079                    &crate::backend::eventbus::WSEventType {
1080                        eventtype: "waveobj:update".to_string(),
1081                        oref,
1082                        data: Some(data),
1083                    },
1084                );
1085            }
1086        }
1087    }
1088
1089    Ok(PaneOpenResult {
1090        block_id,
1091        tab_id,
1092        view: cmd.view,
1093        created: true,
1094    })
1095}
1096
1097/// Floating-pane branch of `open_pane`. The block already exists in
1098/// `source_tab_id`'s blockids (created by the caller, with no layout node).
1099/// This moves it into a fresh floating workspace via the `tear_off_block`
1100/// saga, sets up the new tab's layout, broadcasts the new WaveObjs, and asks
1101/// the source window's frontend to materialize the chromeless floating OS
1102/// window via the host `open_floating_pane_window` command (srv cannot open
1103/// windows itself). See docs/specs/SPEC_OPENEDITOR_FLOATING_AND_COLLAPSED_TREE_2026_06_16.md.
1104async fn open_pane_floating(
1105    state: &AppState,
1106    wstore: &Store,
1107    event_bus: &crate::backend::eventbus::EventBus,
1108    view: String,
1109    source_tab_id: String,
1110    meta: MetaMapType,
1111) -> Result<PaneOpenResult, String> {
1112    use agentmux_common::ipc::{Command, Event};
1113
1114    // Source workspace from the reducer's canonical tab→workspace map.
1115    let source_ws_id = {
1116        let s = state.srv_state.lock().await;
1117        s.tabs
1118            .get(&source_tab_id)
1119            .map(|t| t.workspace_id.clone())
1120            .ok_or_else(|| format!("pane.open: floating: tab {source_tab_id} not in reducer state"))?
1121    };
1122
1123    // Create the block through the reducer (NOT wcore-direct) so it lands in
1124    // `state.blocks` — the `tear_off_block` saga's pre-condition checks the
1125    // reducer-canonical block map. The `BlockCreated` event also carries the
1126    // meta, which `persist_subscriber::apply_block_created` writes into the
1127    // wstore Block so the editor renders with its file + tree state. We skip
1128    // layout placement, so the block never renders docked before the saga
1129    // moves it into the floating workspace (no flash).
1130    let meta_val = serde_json::to_value(&meta)
1131        .map_err(|e| format!("pane.open: floating: meta serialize: {e}"))?;
1132    let create_events = crate::server::service::dispatch_to_reducer(
1133        state,
1134        Command::CreateBlock {
1135            tab_id: source_tab_id.clone(),
1136            meta: meta_val,
1137        },
1138    )
1139    .await;
1140    if let Some(msg) = create_events.iter().find_map(|e| match e {
1141        Event::Error { message, .. } => Some(message.clone()),
1142        _ => None,
1143    }) {
1144        return Err(format!("pane.open: floating: CreateBlock: {msg}"));
1145    }
1146    let block_id = create_events
1147        .iter()
1148        .find_map(|e| match e {
1149            Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
1150            _ => None,
1151        })
1152        .ok_or_else(|| "pane.open: floating: CreateBlock emitted no BlockCreated".to_string())?;
1153    for ev in &create_events {
1154        if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, wstore) {
1155            tracing::warn!("pane.open: floating: CreateBlock wstore apply failed: {e}");
1156        }
1157    }
1158    crate::server::service::publish_events(state, &create_events);
1159
1160    // Tear the block off into a fresh floating workspace + tab (reuses the
1161    // exact saga the drag tear-off uses: CreateWorkspace → CreateTab → MoveBlock).
1162    let saga_val = crate::sagas::tear_off_block::run(
1163        state,
1164        block_id.clone(),
1165        source_tab_id.clone(),
1166        source_ws_id.clone(),
1167    )
1168    .await?;
1169    let new_ws_id = saga_val
1170        .get("new_workspace_id")
1171        .and_then(|v| v.as_str())
1172        .unwrap_or_default()
1173        .to_string();
1174    let new_tab_id = saga_val
1175        .get("new_tab_id")
1176        .and_then(|v| v.as_str())
1177        .unwrap_or_default()
1178        .to_string();
1179    if new_ws_id.is_empty() || new_tab_id.is_empty() {
1180        return Err("pane.open: floating: tear_off_block returned empty ids".to_string());
1181    }
1182
1183    // Make the moved block the new tab's single root node so it renders.
1184    if let Err(e) =
1185        crate::server::service::setup_torn_off_block_layout(wstore, &new_tab_id, &block_id)
1186    {
1187        tracing::warn!(
1188            new_tab = %new_tab_id,
1189            "pane.open: floating: layout setup failed: {e} (block moved but layout malformed)"
1190        );
1191    }
1192
1193    // Broadcast the new workspace + layout + tab + block so any frontend syncs
1194    // its WaveObj cache (mirrors the docked path + the tear-off DnD handler).
1195    {
1196        let mut updates: Vec<obj::WaveObjUpdate> = Vec::new();
1197        if let Ok(ws) = wstore.must_get::<Workspace>(&new_ws_id) {
1198            updates.push(obj::WaveObjUpdate {
1199                updatetype: "update".into(),
1200                otype: "workspace".into(),
1201                oid: new_ws_id.clone(),
1202                obj: Some(obj::wave_obj_to_value(&ws)),
1203            });
1204        }
1205        if let Ok(t) = wstore.must_get::<Tab>(&new_tab_id) {
1206            if let Ok(layout) = wstore.must_get::<obj::LayoutState>(&t.layoutstate) {
1207                updates.push(obj::WaveObjUpdate {
1208                    updatetype: "update".into(),
1209                    otype: "layout".into(),
1210                    oid: t.layoutstate.clone(),
1211                    obj: Some(obj::wave_obj_to_value(&layout)),
1212                });
1213            }
1214            updates.push(obj::WaveObjUpdate {
1215                updatetype: "update".into(),
1216                otype: "tab".into(),
1217                oid: new_tab_id.clone(),
1218                obj: Some(obj::wave_obj_to_value(&t)),
1219            });
1220        }
1221        if let Ok(b) = wstore.must_get::<Block>(&block_id) {
1222            updates.push(obj::WaveObjUpdate {
1223                updatetype: "update".into(),
1224                otype: "block".into(),
1225                oid: block_id.clone(),
1226                obj: Some(obj::wave_obj_to_value(&b)),
1227            });
1228        }
1229        for update in &updates {
1230            let oref = format!("{}:{}", update.otype, update.oid);
1231            if let Ok(data) = serde_json::to_value(update) {
1232                event_bus.broadcast_event(&crate::backend::eventbus::WSEventType {
1233                    eventtype: "waveobj:update".to_string(),
1234                    oref,
1235                    data: Some(data),
1236                });
1237            }
1238        }
1239    }
1240
1241    // Ask the source window's frontend to open the floating OS window — scoped
1242    // to that window (mirrors the window-scoped `userinput` event) so exactly
1243    // one window acts. The frontend handler calls the host
1244    // `open_floating_pane_window` command.
1245    let window_id = {
1246        let s = state.srv_state.lock().await;
1247        s.windows
1248            .iter()
1249            .find(|(_, w)| w.workspace_id == source_ws_id)
1250            .map(|(id, _)| id.clone())
1251    };
1252    match window_id {
1253        Some(win) => {
1254            state.broker.publish(crate::backend::wps::WaveEvent {
1255                event: "openfloatingpane".to_string(),
1256                scopes: vec![win],
1257                sender: String::new(),
1258                persist: 0,
1259                data: Some(json!({
1260                    "block_id": block_id,
1261                    "workspace_id": new_ws_id,
1262                })),
1263            });
1264        }
1265        None => {
1266            tracing::warn!(
1267                source_ws = %source_ws_id,
1268                "pane.open: floating: no window mapped to source workspace — floater not opened"
1269            );
1270        }
1271    }
1272
1273    Ok(PaneOpenResult {
1274        block_id,
1275        tab_id: new_tab_id,
1276        view,
1277        created: true,
1278    })
1279}
1280
1281/// Build the metadata map for a pane.open request, validating required args.
1282fn build_pane_meta(cmd: &CommandPaneOpenData) -> Result<MetaMapType, String> {
1283    let mut meta = MetaMapType::new();
1284
1285    match cmd.view.as_str() {
1286        "editor" => {
1287            let file = cmd.file.as_deref().filter(|s| !s.is_empty())
1288                .ok_or_else(|| "MISSING_ARG: view=editor requires 'file'".to_string())?;
1289            meta.insert("view".to_string(), json!("editor"));
1290            meta.insert("file".to_string(), json!(file));
1291            // Optional initial file-tree state. Only write when explicitly
1292            // requested; absent leaves the frontend default (expanded). The
1293            // frontend collapses iff this is literally `false`
1294            // (EditorViewModel restore: `meta["editor:tree_expanded"] === false`).
1295            if let Some(expanded) = cmd.tree_expanded {
1296                meta.insert("editor:tree_expanded".to_string(), json!(expanded));
1297            }
1298        }
1299        "term" => {
1300            meta.insert("view".to_string(), json!("term"));
1301            meta.insert("controller".to_string(), json!("shell"));
1302            if let Some(cwd) = cmd.cwd.as_deref().filter(|s| !s.is_empty()) {
1303                meta.insert("cmd:cwd".to_string(), json!(cwd));
1304            }
1305        }
1306        "browser" => {
1307            let url = cmd.url.as_deref().filter(|s| !s.is_empty())
1308                .ok_or_else(|| "MISSING_ARG: view=browser requires 'url'".to_string())?;
1309            meta.insert("view".to_string(), json!("browser"));
1310            meta.insert("url".to_string(), json!(url));
1311        }
1312        "sysinfo" => {
1313            meta.insert("view".to_string(), json!("sysinfo"));
1314        }
1315        "help" => {
1316            meta.insert("view".to_string(), json!("help"));
1317        }
1318        other => {
1319            return Err(format!(
1320                "INVALID_VIEW: unsupported view '{other}' (expected editor/term/browser/sysinfo/help)"
1321            ));
1322        }
1323    }
1324
1325    if let Some(title) = cmd.title.as_deref().filter(|s| !s.is_empty()) {
1326        meta.insert("frame:title".to_string(), json!(title));
1327    }
1328
1329    Ok(meta)
1330}
1331
1332/// Translate `split_direction` + `split_reference_block_id` into the backend
1333/// layout action triple. Returns `(actiontype, targetblockid, position)`.
1334/// Falls back to a plain `insert` if direction/reference are missing.
1335fn resolve_placement(
1336    direction: Option<&str>,
1337    reference: Option<&str>,
1338) -> (String, String, String) {
1339    let reference = match reference.filter(|s| !s.is_empty()) {
1340        Some(r) => r,
1341        None => return ("insert".to_string(), String::new(), String::new()),
1342    };
1343
1344    let (actiontype, position) = match direction {
1345        Some("right") => (crate::backend::wcore::LAYOUT_ACTION_SPLIT_HORIZONTAL, "after"),
1346        Some("left") => (crate::backend::wcore::LAYOUT_ACTION_SPLIT_HORIZONTAL, "before"),
1347        Some("down") | Some("below") => (crate::backend::wcore::LAYOUT_ACTION_SPLIT_VERTICAL, "after"),
1348        Some("up") | Some("above") => (crate::backend::wcore::LAYOUT_ACTION_SPLIT_VERTICAL, "before"),
1349        _ => return ("insert".to_string(), String::new(), String::new()),
1350    };
1351
1352    (actiontype.to_string(), reference.to_string(), position.to_string())
1353}
1354
1355// ---------------------------------------------------------------------------
1356// Cross-channel transcript fallback (shared by line_count + read_range)
1357// ---------------------------------------------------------------------------
1358
1359/// If this channel has no local `output` for `block_id` but the agent's GLOBAL
1360/// transcript zone (`agent:<defId>:current`) does, return `(global_store,
1361/// agent_zone)`. Returns `None` when the local output is present and non-empty,
1362/// the block isn't agent-anchored, there's no global store, or the global zone
1363/// is empty — callers then read the per-channel store keyed by `block_id`.
1364///
1365/// Only the agent `output` stream is globalized; every other file stays local.
1366/// This is what makes opening a cross-channel agent show its conversation: the
1367/// freshly-opened local block has no `output`, so the read is transparently
1368/// served from the global zone the agent wrote elsewhere (mirrored live by the
1369/// block-controller hot path, or backfilled).
1370fn global_output_source(
1371    _per_channel: &Arc<crate::backend::storage::filestore::FileStore>,
1372    global: &Option<Arc<crate::backend::storage::filestore::FileStore>>,
1373    wstore: &Arc<crate::backend::storage::store::Store>,
1374    block_id: &str,
1375    filename: &str,
1376) -> Option<(Arc<crate::backend::storage::filestore::FileStore>, String)> {
1377    if filename != crate::backend::agent_session::OUTPUT_FILE {
1378        return None;
1379    }
1380    // Always prefer the global zone when available — it holds the complete
1381    // cross-channel history. Cross-channel opens (per-build portable channels,
1382    // agent reopens in a new session) start with an empty local `output`, so
1383    // the local file only ever holds the current session's lines. Bailing when
1384    // local is non-empty was silently truncating history: after the first
1385    // AgentInput the local had a few lines, and subsequent history loads showed
1386    // only those lines instead of the full global record.
1387    let gfs = global.as_ref()?;
1388    let block = wstore.get::<Block>(block_id).ok().flatten()?;
1389    // Suppress the fallback for an explicitly ARCHIVED block. The UI archive
1390    // button + the periodic `SessionArchiver` sweep delete the local block
1391    // `output` and stamp `session:archived_at` (`archive_session_output`). That
1392    // empty local output is intentional — the block must reopen archived (banner
1393    // + Restore), exactly as pre-PR — so we must NOT resurrect it from the global
1394    // mirror here. (reagent P1 #1399.)
1395    let archived = block
1396        .meta
1397        .get(crate::backend::session_archive::META_SESSION_ARCHIVED_AT)
1398        .and_then(|v| v.as_i64())
1399        .map(|v| v > 0)
1400        .unwrap_or(false);
1401    if archived {
1402        return None;
1403    }
1404    let zone = crate::backend::agent_session::agent_zone_for_block_meta(&block.meta)?;
1405    match gfs.stat(&zone, filename) {
1406        Ok(Some(ref wf)) if wf.size > 0 => Some((gfs.clone(), zone)),
1407        _ => None,
1408    }
1409}
1410
1411/// Exact non-blank line count of the `output` file in `zone`, computed via the
1412/// same streaming index builder `read_range` uses (so the two endpoints always
1413/// agree). Returns `Some(0)` for an empty file, `None` on read failure.
1414fn global_zone_line_count(
1415    gfs: &Arc<crate::backend::storage::filestore::FileStore>,
1416    zone: &str,
1417) -> Option<u64> {
1418    let stat = gfs
1419        .stat(zone, crate::backend::agent_session::OUTPUT_FILE)
1420        .ok()??;
1421    if stat.size == 0 {
1422        return Some(0);
1423    }
1424    crate::backend::blockcontroller::shell::rebuild_output_idx(gfs, zone, stat.size as u64)
1425}
1426
1427// ---------------------------------------------------------------------------
1428// blockfile:line_count
1429// ---------------------------------------------------------------------------
1430
1431fn register_blockfile_line_count(engine: &Arc<WshRpcEngine>, state: &AppState) {
1432    let broker = state.broker.clone();
1433    let wstore = state.wstore.clone();
1434    let filestore = state.filestore.clone();
1435    let global_store = state.global_transcript_store.clone();
1436
1437    engine.register_handler(
1438        COMMAND_BLOCKFILE_LINE_COUNT,
1439        Box::new(move |data, _ctx| {
1440            let broker = broker.clone();
1441            let wstore = wstore.clone();
1442            let filestore = filestore.clone();
1443            let global_store = global_store.clone();
1444            Box::pin(async move {
1445                let cmd: CommandBlockfileLineCountData = serde_json::from_value(data)
1446                    .map_err(|e| format!("blockfile:line_count: {e}"))?;
1447
1448                tracing::info!(block_id = %cmd.block_id, filename = %cmd.filename, "blockfile:line_count");
1449
1450                // Cross-channel fallback (checked first): when this channel has
1451                // no local `output` for the block, the local `session:line_count`
1452                // meta is absent/stale, so a fresh cross-channel open would
1453                // report 0 lines and the pane would render empty. Count from the
1454                // agent's GLOBAL transcript zone instead. See
1455                // `docs/analysis/ANALYSIS_CROSS_CHANNEL_CONVERSATION_HISTORY_2026_06_14.md`.
1456                if let Some((gfs, zone)) =
1457                    global_output_source(&filestore, &global_store, &wstore, &cmd.block_id, &cmd.filename)
1458                {
1459                    if let Some(count) = global_zone_line_count(&gfs, &zone) {
1460                        return Ok(Some(
1461                            serde_json::to_value(&BlockfileLineCountResult { count }).unwrap(),
1462                        ));
1463                    }
1464                }
1465
1466                // Fast path: read session:line_count meta (O(1), maintained
1467                // by SessionStatsAccumulator). For "output" filename this is
1468                // the authoritative total — matches the unbounded counter
1469                // that SessionStats increments on every line. FileStore's
1470                // persisted line count will trail meta by up to the debounce
1471                // interval (1s), and reading the full file just to count
1472                // lines is O(file size) which defeats the point of a fast
1473                // line_count endpoint.
1474                if cmd.filename == "output" {
1475                    if let Ok(Some(block)) = wstore.get::<Block>(&cmd.block_id) {
1476                        if let Some(count) = block.meta.get("session:line_count").and_then(|v| v.as_u64()) {
1477                            return Ok(Some(serde_json::to_value(
1478                                &BlockfileLineCountResult { count },
1479                            ).unwrap()));
1480                        }
1481                    }
1482                }
1483
1484                // Fallback: count from WPS event ring buffer (capped at MAX_PERSIST = 4096).
1485                let scope = format!("block:{}", cmd.block_id);
1486                let events = broker.read_event_history(
1487                    crate::backend::wps::EVENT_BLOCK_FILE,
1488                    &scope,
1489                    usize::MAX, // broker clamps to MAX_PERSIST internally
1490                );
1491
1492                let mut count: u64 = 0;
1493                for event in events {
1494                    if let Some(ref event_data) = event.data {
1495                        let ev_filename = event_data.get("filename")
1496                            .and_then(|v| v.as_str()).unwrap_or("");
1497                        if ev_filename != cmd.filename {
1498                            continue;
1499                        }
1500                        if let Some(data64) = event_data.get("data64").and_then(|v| v.as_str()) {
1501                            if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data64) {
1502                                let text = String::from_utf8_lossy(&bytes);
1503                                for line in text.lines() {
1504                                    if !line.trim().is_empty() {
1505                                        count += 1;
1506                                    }
1507                                }
1508                            }
1509                        }
1510                    }
1511                }
1512
1513                Ok(Some(serde_json::to_value(&BlockfileLineCountResult { count }).unwrap()))
1514            })
1515        }),
1516    );
1517}
1518
1519// ---------------------------------------------------------------------------
1520// blockfile:read_range
1521// ---------------------------------------------------------------------------
1522
1523fn register_blockfile_read_range(engine: &Arc<WshRpcEngine>, state: &AppState) {
1524    let broker = state.broker.clone();
1525    let filestore = state.filestore.clone();
1526    let global_store = state.global_transcript_store.clone();
1527    let wstore = state.wstore.clone();
1528
1529    engine.register_handler(
1530        COMMAND_BLOCKFILE_READ_RANGE,
1531        Box::new(move |data, _ctx| {
1532            let broker = broker.clone();
1533            let filestore = filestore.clone();
1534            let global_store = global_store.clone();
1535            let wstore = wstore.clone();
1536            Box::pin(async move {
1537                let cmd: CommandBlockfileReadRangeData = serde_json::from_value(data)
1538                    .map_err(|e| format!("blockfile:read_range: {e}"))?;
1539
1540                tracing::info!(block_id = %cmd.block_id, filename = %cmd.filename, offset = cmd.offset, limit = cmd.limit, "blockfile:read_range");
1541
1542                let limit = cmd.limit.min(10_000) as usize;
1543                let offset = cmd.offset as usize;
1544                let end = offset.saturating_add(limit);
1545
1546                // Cross-channel fallback: when this channel has no local `output`
1547                // for the block, read the agent's GLOBAL transcript zone
1548                // (`agent:<defId>:current`) instead. `read_block` is the zone for
1549                // every FileStore call below — the local block_id normally, the
1550                // agent zone when the agent ran in another build/channel.
1551                let (filestore, read_block) =
1552                    global_output_source(&filestore, &global_store, &wstore, &cmd.block_id, &cmd.filename)
1553                        .unwrap_or_else(|| (filestore.clone(), cmd.block_id.clone()));
1554
1555                // Fast path: output.idx — a lazily-built, self-validating byte-offset
1556                // index of every non-blank line in `output`. It lets us seek directly
1557                // to the requested line range instead of loading the whole file.
1558                //
1559                // The index is a pure cache of `output` with NO incremental mutation:
1560                // its 8-byte header records the `output` size it was built for. If that
1561                // equals `output`'s current size the index is fresh; otherwise we rebuild
1562                // it from a single streaming scan (rebuild_output_idx). Because the index
1563                // is always derived from the current `output` in one shot, it can never
1564                // desync, mis-handle chunk-split lines, or miscount blank lines — the
1565                // failure modes an incremental index would have.
1566                //
1567                // Gated to non-circular files: circular `output` (terminal ring buffers)
1568                // drops early bytes, so absolute byte offsets wouldn't map cleanly.
1569                use crate::backend::blockcontroller::shell::{rebuild_output_idx, OUTPUT_IDX_HEADER_LEN};
1570                if cmd.filename == "output" {
1571                    let idx_result: Option<BlockfileReadRangeResult> = (|| {
1572                        let out_stat = filestore.stat(&read_block, "output").ok()??;
1573                        if out_stat.opts.circular {
1574                            return None; // circular files: fall back to slow path
1575                        }
1576                        let output_size = out_stat.size as u64;
1577
1578                        // Determine total_lines, rebuilding the index iff it is missing or
1579                        // its covered-size header doesn't match the current output size.
1580                        let idx_stat = filestore.stat(&read_block, "output.idx").ok().flatten();
1581                        let fresh = match &idx_stat {
1582                            Some(s) if s.size >= OUTPUT_IDX_HEADER_LEN => {
1583                                let (_, h) = filestore
1584                                    .read_at(&read_block, "output.idx", 0, OUTPUT_IDX_HEADER_LEN)
1585                                    .ok()?;
1586                                u64::from_le_bytes(h.try_into().ok()?) == output_size
1587                            }
1588                            _ => false,
1589                        };
1590                        let total_lines: u64 = if fresh {
1591                            let s = idx_stat.unwrap();
1592                            ((s.size - OUTPUT_IDX_HEADER_LEN) / 8) as u64
1593                        } else {
1594                            rebuild_output_idx(&filestore, &read_block, output_size)?
1595                        };
1596
1597                        // Empty result cases — answered from the index, no output read.
1598                        if limit == 0 || total_lines == 0 || (offset as u64) >= total_lines {
1599                            return Some(BlockfileReadRangeResult { lines: vec![], total: total_lines });
1600                        }
1601
1602                        // entry(k) = byte offset of non-blank line k (past the 8-byte header).
1603                        let entry = |k: u64| -> Option<i64> {
1604                            let (_, b) = filestore
1605                                .read_at(
1606                                    &read_block,
1607                                    "output.idx",
1608                                    OUTPUT_IDX_HEADER_LEN + (k * 8) as i64,
1609                                    8,
1610                                )
1611                                .ok()?;
1612                            Some(u64::from_le_bytes(b.try_into().ok()?) as i64)
1613                        };
1614
1615                        let byte_start = entry(offset as u64)?;
1616                        let byte_end: i64 = if (offset + limit) as u64 >= total_lines {
1617                            output_size as i64
1618                        } else {
1619                            entry((offset + limit) as u64)?
1620                        };
1621                        let read_len = (byte_end - byte_start).max(0);
1622                        let (_, raw) = filestore
1623                            .read_at(&read_block, "output", byte_start, read_len)
1624                            .ok()?;
1625                        let text = String::from_utf8_lossy(&raw);
1626                        let lines: Vec<String> = text
1627                            .lines()
1628                            .filter(|l| !l.trim().is_empty())
1629                            .map(|l| l.to_string())
1630                            .collect();
1631                        Some(BlockfileReadRangeResult { lines, total: total_lines })
1632                    })();
1633                    if let Some(result) = idx_result {
1634                        tracing::debug!(
1635                            block_id = %cmd.block_id,
1636                            offset,
1637                            limit,
1638                            lines = result.lines.len(),
1639                            "blockfile:read_range via output.idx fast path"
1640                        );
1641                        return Ok(Some(serde_json::to_value(&result).unwrap()));
1642                    }
1643                }
1644
1645                // Phase 1.3: Prefer FileStore (persistent, no size cap) over the
1646                // WPS broker ring buffer (MAX_PERSIST = 4096 events).
1647                //
1648                // If FileStore has the file and it is non-empty, read from disk.
1649                // Otherwise fall back to ring buffer for backward compatibility.
1650                let filestore_lines = match filestore.stat(&read_block, &cmd.filename) {
1651                    Ok(Some(ref wf)) if wf.size > 0 => {
1652                        match filestore.read_file(&read_block, &cmd.filename) {
1653                            Ok(Some(bytes)) => {
1654                                let text = String::from_utf8_lossy(&bytes);
1655                                let lines: Vec<String> = text.lines()
1656                                    .filter(|l| !l.trim().is_empty())
1657                                    .map(|l| l.to_string())
1658                                    .collect();
1659                                Some(lines)
1660                            }
1661                            Ok(None) => None,
1662                            Err(e) => {
1663                                tracing::warn!(
1664                                    block_id = %cmd.block_id,
1665                                    filename = %cmd.filename,
1666                                    error = %e,
1667                                    "blockfile:read_range: filestore read failed, falling back to ring buffer"
1668                                );
1669                                None
1670                            }
1671                        }
1672                    }
1673                    Ok(_) => None, // file absent or empty → fall back
1674                    Err(e) => {
1675                        tracing::warn!(
1676                            block_id = %cmd.block_id,
1677                            error = %e,
1678                            "blockfile:read_range: filestore stat failed, falling back to ring buffer"
1679                        );
1680                        None
1681                    }
1682                };
1683
1684                let all_lines = if let Some(lines) = filestore_lines {
1685                    lines
1686                } else {
1687                    // Fallback: reconstruct from WPS event ring buffer.
1688                    // The ring buffer holds at most MAX_PERSIST = 4096 events;
1689                    // older events are evicted. Offset 0 = oldest retained line.
1690                    let scope = format!("block:{}", cmd.block_id);
1691                    let events = broker.read_event_history(
1692                        crate::backend::wps::EVENT_BLOCK_FILE,
1693                        &scope,
1694                        usize::MAX, // broker clamps to MAX_PERSIST internally
1695                    );
1696
1697                    let mut lines: Vec<String> = Vec::new();
1698                    for event in events {
1699                        let Some(ref event_data) = event.data else { continue };
1700                        let ev_filename = event_data.get("filename")
1701                            .and_then(|v| v.as_str()).unwrap_or("");
1702                        if ev_filename != cmd.filename {
1703                            continue;
1704                        }
1705                        let Some(data64) = event_data.get("data64").and_then(|v| v.as_str()) else { continue };
1706                        let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data64) else { continue };
1707                        let text = String::from_utf8_lossy(&bytes);
1708                        for line in text.lines() {
1709                            if !line.trim().is_empty() {
1710                                lines.push(line.to_string());
1711                            }
1712                        }
1713                    }
1714                    lines
1715                };
1716
1717                let total = all_lines.len() as u64;
1718                let clamped_offset = offset.min(all_lines.len());
1719                let clamped_end = end.min(all_lines.len());
1720                let lines: Vec<String> = if clamped_offset >= clamped_end {
1721                    Vec::new()
1722                } else {
1723                    all_lines[clamped_offset..clamped_end].to_vec()
1724                };
1725
1726                Ok(Some(serde_json::to_value(&BlockfileReadRangeResult {
1727                    lines,
1728                    total,
1729                }).unwrap()))
1730            })
1731        }),
1732    );
1733}
1734
1735// ---------------------------------------------------------------------------
1736// blockfile:read_state — sidecar JSON snapshot read
1737// Spec: docs/specs/SPEC_AGENT_PANE_STATE_PERSISTENCE_2026_05_15.md §4.6
1738// ---------------------------------------------------------------------------
1739
1740fn register_blockfile_read_state(engine: &Arc<WshRpcEngine>, state: &AppState) {
1741    let filestore = state.filestore.clone();
1742    engine.register_handler(
1743        COMMAND_BLOCKFILE_READ_STATE,
1744        Box::new(move |data, _ctx| {
1745            let filestore = filestore.clone();
1746            Box::pin(async move {
1747                let cmd: CommandBlockfileReadStateData = serde_json::from_value(data)
1748                    .map_err(|e| format!("blockfile:read_state: {e}"))?;
1749                if cmd.filename.contains('/') || cmd.filename.contains('\\') || cmd.filename.contains("..") {
1750                    return Err("blockfile:read_state: filename must not contain path separators".to_string());
1751                }
1752                tracing::debug!(block_id = %cmd.block_id, filename = %cmd.filename, "blockfile:read_state");
1753
1754                let content = match filestore.read_file(&cmd.block_id, &cmd.filename) {
1755                    Ok(Some(bytes)) => Some(String::from_utf8_lossy(&bytes).into_owned()),
1756                    Ok(None) => None,
1757                    Err(e) => {
1758                        // NotFound is the common case (no snapshot yet). Suppress.
1759                        if matches!(e, crate::backend::storage::StoreError::NotFound) {
1760                            None
1761                        } else {
1762                            tracing::warn!(block_id = %cmd.block_id, error = %e, "blockfile:read_state: read failed");
1763                            None
1764                        }
1765                    }
1766                };
1767
1768                Ok(Some(serde_json::to_value(&BlockfileReadStateResult { content }).unwrap()))
1769            })
1770        }),
1771    );
1772}
1773
1774// ---------------------------------------------------------------------------
1775// blockfile:write_state — sidecar JSON snapshot write (atomic via DB tx)
1776// Spec: docs/specs/SPEC_AGENT_PANE_STATE_PERSISTENCE_2026_05_15.md §4.3
1777// ---------------------------------------------------------------------------
1778
1779fn register_blockfile_write_state(engine: &Arc<WshRpcEngine>, state: &AppState) {
1780    let filestore = state.filestore.clone();
1781    engine.register_handler(
1782        COMMAND_BLOCKFILE_WRITE_STATE,
1783        Box::new(move |data, _ctx| {
1784            let filestore = filestore.clone();
1785            Box::pin(async move {
1786                let cmd: CommandBlockfileWriteStateData = serde_json::from_value(data)
1787                    .map_err(|e| format!("blockfile:write_state: {e}"))?;
1788                if cmd.filename.contains('/') || cmd.filename.contains('\\') || cmd.filename.contains("..") {
1789                    return Err("blockfile:write_state: filename must not contain path separators".to_string());
1790                }
1791                let bytes = cmd.content.as_bytes();
1792                let bytes_written = bytes.len() as u64;
1793                tracing::debug!(block_id = %cmd.block_id, filename = %cmd.filename, bytes = bytes_written, "blockfile:write_state");
1794
1795                // FileStore.write_file is atomic at the DB level (single
1796                // tx replaces all data parts) — no torn write surfaces.
1797                // Need make_file first if the sidecar doesn't yet exist.
1798                use crate::backend::storage::filestore::{FileMeta, FileOpts};
1799                use crate::backend::storage::StoreError;
1800                match filestore.write_file(&cmd.block_id, &cmd.filename, bytes) {
1801                    Ok(()) => {}
1802                    Err(StoreError::NotFound) => {
1803                        filestore
1804                            .make_file(&cmd.block_id, &cmd.filename, FileMeta::default(), FileOpts::default())
1805                            .map_err(|e| format!("blockfile:write_state: make_file: {e}"))?;
1806                        filestore
1807                            .write_file(&cmd.block_id, &cmd.filename, bytes)
1808                            .map_err(|e| format!("blockfile:write_state: write_file: {e}"))?;
1809                    }
1810                    Err(e) => return Err(format!("blockfile:write_state: {e}")),
1811                }
1812
1813                Ok(Some(serde_json::to_value(&BlockfileWriteStateResult { bytes_written }).unwrap()))
1814            })
1815        }),
1816    );
1817}
1818
1819// ---------------------------------------------------------------------------
1820// session:archive
1821// ---------------------------------------------------------------------------
1822
1823fn register_session_archive_handler(engine: &Arc<WshRpcEngine>, state: &AppState) {
1824    let wstore = state.wstore.clone();
1825    let filestore = state.filestore.clone();
1826
1827    engine.register_handler(
1828        COMMAND_SESSION_ARCHIVE,
1829        Box::new(move |data, _ctx| {
1830            let wstore = wstore.clone();
1831            let filestore = filestore.clone();
1832            Box::pin(async move {
1833                let cmd: CommandSessionArchiveData = serde_json::from_value(data)
1834                    .map_err(|e| format!("session:archive: {e}"))?;
1835
1836                tracing::info!(block_id = %cmd.block_id, "session:archive");
1837
1838                let archive_dir = session_archive::default_archive_dir()
1839                    .ok_or_else(|| "cannot determine home directory".to_string())?;
1840
1841                let (archived_bytes, archived_at) = session_archive::archive_session_output(
1842                    &wstore,
1843                    &filestore,
1844                    &cmd.block_id,
1845                    &archive_dir,
1846                )?;
1847
1848                Ok(Some(serde_json::to_value(&SessionArchiveResult {
1849                    block_id: cmd.block_id,
1850                    archived_bytes,
1851                    archived_at,
1852                }).unwrap()))
1853            })
1854        }),
1855    );
1856}
1857
1858// ---------------------------------------------------------------------------
1859// session:restore
1860// ---------------------------------------------------------------------------
1861
1862fn register_session_restore_handler(engine: &Arc<WshRpcEngine>, state: &AppState) {
1863    let wstore = state.wstore.clone();
1864    let filestore = state.filestore.clone();
1865
1866    engine.register_handler(
1867        COMMAND_SESSION_RESTORE,
1868        Box::new(move |data, _ctx| {
1869            let wstore = wstore.clone();
1870            let filestore = filestore.clone();
1871            Box::pin(async move {
1872                let cmd: CommandSessionRestoreData = serde_json::from_value(data)
1873                    .map_err(|e| format!("session:restore: {e}"))?;
1874
1875                tracing::info!(block_id = %cmd.block_id, "session:restore");
1876
1877                let restored_bytes = session_archive::restore_session_output(
1878                    &wstore,
1879                    &filestore,
1880                    &cmd.block_id,
1881                )?;
1882
1883                Ok(Some(serde_json::to_value(&SessionRestoreResult {
1884                    block_id: cmd.block_id,
1885                    restored_bytes,
1886                }).unwrap()))
1887            })
1888        }),
1889    );
1890}
1891
1892// ---------------------------------------------------------------------------
1893// session:export
1894// ---------------------------------------------------------------------------
1895
1896fn register_session_export_handler(engine: &Arc<WshRpcEngine>, state: &AppState) {
1897    let wstore = state.wstore.clone();
1898    let filestore = state.filestore.clone();
1899
1900    engine.register_handler(
1901        COMMAND_SESSION_EXPORT,
1902        Box::new(move |data, _ctx| {
1903            let wstore = wstore.clone();
1904            let filestore = filestore.clone();
1905            Box::pin(async move {
1906                let cmd: CommandSessionExportData = serde_json::from_value(data)
1907                    .map_err(|e| format!("session:export: {e}"))?;
1908
1909                tracing::info!(block_id = %cmd.block_id, "session:export");
1910
1911                let (raw_bytes, line_count) = session_archive::read_session_output(
1912                    &wstore,
1913                    &filestore,
1914                    &cmd.block_id,
1915                )?;
1916
1917                let byte_count = raw_bytes.len() as u64;
1918                let content = base64::engine::general_purpose::STANDARD.encode(&raw_bytes);
1919
1920                Ok(Some(serde_json::to_value(&SessionExportResult {
1921                    content,
1922                    line_count,
1923                    byte_count,
1924                }).unwrap()))
1925            })
1926        }),
1927    );
1928}
1929
1930// ---------------------------------------------------------------------------
1931// session:digest
1932// ---------------------------------------------------------------------------
1933
1934fn register_session_digest(engine: &Arc<WshRpcEngine>, state: &AppState) {
1935    let wstore = state.wstore.clone();
1936    let filestore = state.filestore.clone();
1937    let broker = state.broker.clone();
1938
1939    engine.register_handler(
1940        COMMAND_SESSION_DIGEST,
1941        Box::new(move |data, _ctx| {
1942            let wstore = wstore.clone();
1943            let filestore = filestore.clone();
1944            let broker = broker.clone();
1945            Box::pin(async move {
1946                let cmd: CommandSessionDigestData = serde_json::from_value(data)
1947                    .map_err(|e| format!("session:digest: {e}"))?;
1948
1949                tracing::info!(block_id = %cmd.block_id, force = ?cmd.force, "session:digest");
1950
1951                let force = cmd.force.unwrap_or(false);
1952
1953                // Read block meta
1954                let block: Block = wstore
1955                    .get(&cmd.block_id)
1956                    .map_err(|e| format!("session:digest: {e}"))?
1957                    .ok_or_else(|| format!("BLOCK_NOT_FOUND: {}", cmd.block_id))?;
1958
1959                // Check for a valid cached digest
1960                let cached_summary = block.meta.get("session:digest_summary")
1961                    .and_then(|v| v.as_str())
1962                    .map(|s| s.to_string());
1963                let cached_generated_at = block.meta.get("session:digest_generated_at")
1964                    .and_then(|v| v.as_i64())
1965                    .unwrap_or(0);
1966                let digest_last_line_count = block.meta.get("session:digest_last_line_count")
1967                    .and_then(|v| v.as_u64())
1968                    .unwrap_or(0);
1969
1970                // Current line count from meta (O(1))
1971                let current_line_count = block.meta.get("session:line_count")
1972                    .and_then(|v| v.as_u64())
1973                    .unwrap_or(0);
1974
1975                // Serve cache if: not forced, cached digest exists, AND fewer than 20 new lines
1976                // since the digest was last generated.
1977                let lines_since_digest = current_line_count.saturating_sub(digest_last_line_count);
1978                if !force && cached_summary.is_some() && lines_since_digest < 20 {
1979                    return Ok(Some(serde_json::to_value(&SessionDigestResult {
1980                        summary: cached_summary.unwrap(),
1981                        generated_at: cached_generated_at,
1982                        cached: true,
1983                    }).unwrap()));
1984                }
1985
1986                // --- Generate a new digest ---
1987
1988                // Read up to the last 200 lines from FileStore, falling back to the WPS ring buffer.
1989                let all_lines: Vec<String> = {
1990                    let filestore_lines = match filestore.stat(&cmd.block_id, "output") {
1991                        Ok(Some(ref wf)) if wf.size > 0 => {
1992                            match filestore.read_file(&cmd.block_id, "output") {
1993                                Ok(Some(bytes)) => {
1994                                    let text = String::from_utf8_lossy(&bytes);
1995                                    let lines: Vec<String> = text.lines()
1996                                        .filter(|l| !l.trim().is_empty())
1997                                        .map(|l| l.to_string())
1998                                        .collect();
1999                                    Some(lines)
2000                                }
2001                                _ => None,
2002                            }
2003                        }
2004                        _ => None,
2005                    };
2006
2007                    if let Some(lines) = filestore_lines {
2008                        lines
2009                    } else {
2010                        // Fallback: WPS ring buffer
2011                        let scope = format!("block:{}", cmd.block_id);
2012                        let events = broker.read_event_history(
2013                            crate::backend::wps::EVENT_BLOCK_FILE,
2014                            &scope,
2015                            usize::MAX,
2016                        );
2017                        let mut lines: Vec<String> = Vec::new();
2018                        for event in events {
2019                            let Some(ref ed) = event.data else { continue };
2020                            let fname = ed.get("filename").and_then(|v| v.as_str()).unwrap_or("");
2021                            if fname != "output" { continue; }
2022                            if let Some(d64) = ed.get("data64").and_then(|v| v.as_str()) {
2023                                if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(d64) {
2024                                    let text = String::from_utf8_lossy(&bytes);
2025                                    for line in text.lines() {
2026                                        if !line.trim().is_empty() {
2027                                            lines.push(line.to_string());
2028                                        }
2029                                    }
2030                                }
2031                            }
2032                        }
2033                        lines
2034                    }
2035                };
2036
2037                // Take the last 200 lines
2038                let n = all_lines.len();
2039                let start = n.saturating_sub(200);
2040                let window: Vec<&str> = all_lines[start..].iter().map(|s| s.as_str()).collect();
2041
2042                if window.is_empty() {
2043                    return Ok(Some(serde_json::to_value(&SessionDigestResult {
2044                        summary: String::new(),
2045                        generated_at: 0,
2046                        cached: false,
2047                    }).unwrap()));
2048                }
2049
2050                // Extract meaningful text (skip system events and raw stream deltas)
2051                let extracted = extract_digest_text(&window);
2052
2053                if extracted.is_empty() {
2054                    return Ok(Some(serde_json::to_value(&SessionDigestResult {
2055                        summary: String::new(),
2056                        generated_at: 0,
2057                        cached: false,
2058                    }).unwrap()));
2059                }
2060
2061                // Locate the Claude CLI (stored in block meta as "cmd" by runLaunchFlow)
2062                let cli_path = obj::meta_get_string(&block.meta, "cmd", "");
2063                if cli_path.is_empty() {
2064                    tracing::warn!(block_id = %cmd.block_id, "session:digest: no CLI path in meta");
2065                    return Ok(Some(serde_json::to_value(&SessionDigestResult {
2066                        summary: String::new(),
2067                        generated_at: 0,
2068                        cached: false,
2069                    }).unwrap()));
2070                }
2071
2072                // Build the summarization prompt
2073                let prompt = format!(
2074                    "Summarize this AI coding session in 10 words or fewer. Be direct and specific. \
2075                     Example: \"Fixed auth bug, added dark mode, tests passing.\"\n\n\
2076                     Session content (last 200 events):\n\n{}",
2077                    extracted
2078                );
2079
2080                // Invoke the Claude CLI and extract the summary text
2081                let summary = match invoke_cli_for_digest(&cli_path, &prompt, &block.meta).await {
2082                    Ok(text) => text,
2083                    Err(e) => {
2084                        tracing::warn!(block_id = %cmd.block_id, error = %e, "session:digest: CLI invocation failed");
2085                        String::new()
2086                    }
2087                };
2088
2089                if summary.is_empty() {
2090                    return Ok(Some(serde_json::to_value(&SessionDigestResult {
2091                        summary: String::new(),
2092                        generated_at: 0,
2093                        cached: false,
2094                    }).unwrap()));
2095                }
2096
2097                // Cache in block meta
2098                let generated_at = std::time::SystemTime::now()
2099                    .duration_since(std::time::UNIX_EPOCH)
2100                    .map(|d| d.as_millis() as i64)
2101                    .unwrap_or(0);
2102
2103                let mut meta_update = obj::MetaMapType::new();
2104                meta_update.insert("session:digest_summary".to_string(), json!(summary.clone()));
2105                meta_update.insert("session:digest_generated_at".to_string(), json!(generated_at));
2106                meta_update.insert("session:digest_last_line_count".to_string(), json!(current_line_count));
2107
2108                if let Err(e) = crate::server::service::update_object_meta(
2109                    &wstore,
2110                    &format!("block:{}", cmd.block_id),
2111                    &meta_update,
2112                ) {
2113                    tracing::warn!(block_id = %cmd.block_id, error = %e, "session:digest: failed to cache in meta");
2114                }
2115
2116                Ok(Some(serde_json::to_value(&SessionDigestResult {
2117                    summary,
2118                    generated_at,
2119                    cached: false,
2120                }).unwrap()))
2121            })
2122        }),
2123    );
2124}
2125
2126// ---------------------------------------------------------------------------
2127// session:activity_summary — per-turn live mini-summary via Haiku
2128// ---------------------------------------------------------------------------
2129
2130fn register_session_activity_summary(engine: &Arc<WshRpcEngine>, state: &AppState) {
2131    let wstore = state.wstore.clone();
2132    let broker = state.broker.clone();
2133
2134    engine.register_handler(
2135        COMMAND_SESSION_ACTIVITY_SUMMARY,
2136        Box::new(move |data, _ctx| {
2137            let wstore = wstore.clone();
2138            let broker = broker.clone();
2139            Box::pin(async move {
2140                let cmd: CommandActivitySummaryData = serde_json::from_value(data)
2141                    .map_err(|e| format!("session:activity_summary: {e}"))?;
2142
2143                let word_target = cmd.word_target.unwrap_or(7).max(3).min(20);
2144
2145                let block: Block = wstore
2146                    .get(&cmd.block_id)
2147                    .map_err(|e| format!("session:activity_summary: {e}"))?
2148                    .ok_or_else(|| format!("BLOCK_NOT_FOUND: {}", cmd.block_id))?;
2149
2150                // Read only the most recent ring buffer events — we need the last ~30
2151                // lines, so 50 events is a generous upper bound without touching the
2152                // full buffer (which can be large on long sessions).
2153                let all_lines: Vec<String> = {
2154                    let scope = format!("block:{}", cmd.block_id);
2155                    let events = broker.read_event_history(
2156                        crate::backend::wps::EVENT_BLOCK_FILE,
2157                        &scope,
2158                        50,
2159                    );
2160                    let mut lines: Vec<String> = Vec::new();
2161                    for event in events {
2162                        let Some(ref ed) = event.data else { continue };
2163                        if ed.get("filename").and_then(|v| v.as_str()).unwrap_or("") != "output" { continue; }
2164                        if let Some(d64) = ed.get("data64").and_then(|v| v.as_str()) {
2165                            if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(d64) {
2166                                let text = String::from_utf8_lossy(&bytes);
2167                                for line in text.lines() {
2168                                    if !line.trim().is_empty() {
2169                                        lines.push(line.to_string());
2170                                    }
2171                                }
2172                            }
2173                        }
2174                    }
2175                    lines
2176                };
2177
2178                let n = all_lines.len();
2179                let start = n.saturating_sub(30);
2180                let window: Vec<&str> = all_lines[start..].iter().map(|s| s.as_str()).collect();
2181
2182                if window.is_empty() {
2183                    return Ok(Some(serde_json::to_value(&ActivitySummaryResult {
2184                        summary: String::new(),
2185                    }).unwrap()));
2186                }
2187
2188                let extracted = extract_digest_text(&window);
2189                if extracted.is_empty() {
2190                    return Ok(Some(serde_json::to_value(&ActivitySummaryResult {
2191                        summary: String::new(),
2192                    }).unwrap()));
2193                }
2194
2195                let cli_path = obj::meta_get_string(&block.meta, "cmd", "");
2196                if cli_path.is_empty() {
2197                    tracing::debug!(block_id = %cmd.block_id, "session:activity_summary: no CLI path in meta");
2198                    return Ok(Some(serde_json::to_value(&ActivitySummaryResult {
2199                        summary: String::new(),
2200                    }).unwrap()));
2201                }
2202
2203                let prompt = format!(
2204                    "Summarize in {word_target} words or fewer what is currently being worked on. \
2205                     Use a short terse phrase with no quotes or punctuation.\n\n\
2206                     Recent activity:\n\n{extracted}"
2207                );
2208
2209                let summary = invoke_cli_for_activity(&cli_path, &prompt, &block.meta).await
2210                    .unwrap_or_else(|e| {
2211                        tracing::debug!(block_id = %cmd.block_id, error = %e, "session:activity_summary: CLI failed");
2212                        String::new()
2213                    });
2214
2215                // The frontend writes `term:activity` after receiving this response so it
2216                // can discard results from turns that were superseded before they returned.
2217                Ok(Some(serde_json::to_value(&ActivitySummaryResult { summary }).unwrap()))
2218            })
2219        }),
2220    );
2221}
2222
2223/// Invoke the Claude CLI with Haiku model for a lightweight per-turn activity summary.
2224/// Uses `--model claude-haiku-4-5-20251001` and a 15s timeout.
2225async fn invoke_cli_for_activity(
2226    cli_path: &str,
2227    prompt: &str,
2228    meta: &obj::MetaMapType,
2229) -> Result<String, String> {
2230    let auth_env: std::collections::HashMap<String, String> = match meta.get("cmd:env") {
2231        Some(serde_json::Value::Object(obj_map)) => obj_map
2232            .iter()
2233            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2234            .collect(),
2235        _ => std::collections::HashMap::new(),
2236    };
2237
2238    let mut child = crate::server::cli_handlers::make_cli_cmd(cli_path)
2239        .args(["-p", "--output-format", "stream-json", "--verbose",
2240               "--model", "claude-haiku-4-5-20251001"])
2241        .envs(&auth_env)
2242        .stdin(std::process::Stdio::piped())
2243        .stdout(std::process::Stdio::piped())
2244        .stderr(std::process::Stdio::null())
2245        .kill_on_drop(true)
2246        .spawn()
2247        .map_err(|e| format!("failed to spawn activity CLI: {e}"))?;
2248
2249    if let Some(mut stdin) = child.stdin.take() {
2250        use tokio::io::AsyncWriteExt;
2251        stdin.write_all(prompt.as_bytes()).await
2252            .map_err(|e| format!("activity CLI stdin write: {e}"))?;
2253        stdin.shutdown().await
2254            .map_err(|e| format!("activity CLI stdin shutdown: {e}"))?;
2255    }
2256
2257    let output = tokio::time::timeout(
2258        std::time::Duration::from_secs(15),
2259        child.wait_with_output(),
2260    )
2261    .await
2262    .map_err(|_| "activity CLI timed out after 15s".to_string())?
2263    .map_err(|e| format!("activity CLI wait: {e}"))?;
2264
2265    if !output.status.success() {
2266        return Err(format!("activity CLI exited with status {}", output.status));
2267    }
2268
2269    let stdout = String::from_utf8_lossy(&output.stdout);
2270    let mut last_text = String::new();
2271    for line in stdout.lines() {
2272        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else { continue };
2273        if val.get("type").and_then(|v| v.as_str()) == Some("assistant") {
2274            if let Some(content) = val.get("message")
2275                .and_then(|m| m.get("content"))
2276                .and_then(|c| c.as_array())
2277            {
2278                for block in content {
2279                    if block.get("type").and_then(|v| v.as_str()) == Some("text") {
2280                        if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
2281                            last_text = text.trim().to_string();
2282                        }
2283                    }
2284                }
2285            }
2286        }
2287    }
2288
2289    if last_text.is_empty() {
2290        return Err("no text in activity CLI response".to_string());
2291    }
2292
2293    Ok(last_text)
2294}
2295
2296/// Extract meaningful text from raw stream-json lines for digest summarization.
2297/// Skips system/result events and raw stream_event deltas; extracts assistant text
2298/// and tool call summaries.
2299fn extract_digest_text(lines: &[&str]) -> String {
2300    let mut parts: Vec<String> = Vec::new();
2301
2302    for line in lines {
2303        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else { continue };
2304
2305        let msg_type = val.get("type").and_then(|v| v.as_str()).unwrap_or("");
2306
2307        match msg_type {
2308            "assistant" => {
2309                if let Some(content) = val.get("message")
2310                    .and_then(|m| m.get("content"))
2311                    .and_then(|c| c.as_array())
2312                {
2313                    for block in content {
2314                        let btype = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
2315                        if btype == "text" {
2316                            if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
2317                                let trimmed = text.trim();
2318                                if !trimmed.is_empty() {
2319                                    parts.push(format!("[assistant] {}", trimmed));
2320                                }
2321                            }
2322                        } else if btype == "tool_use" {
2323                            let tool_name = block.get("name")
2324                                .and_then(|v| v.as_str())
2325                                .unwrap_or("unknown");
2326                            parts.push(format!("[tool] {}", tool_name));
2327                        }
2328                    }
2329                }
2330            }
2331            "user" => {
2332                if let Some(content) = val.get("message")
2333                    .and_then(|m| m.get("content"))
2334                    .and_then(|c| c.as_array())
2335                {
2336                    for block in content {
2337                        let btype = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
2338                        if btype == "tool_result" {
2339                            let is_error = block.get("is_error")
2340                                .and_then(|v| v.as_bool())
2341                                .unwrap_or(false);
2342                            if is_error {
2343                                let err_text = block.get("content")
2344                                    .and_then(|c| c.as_str())
2345                                    .unwrap_or("(error)")
2346                                    .chars().take(120).collect::<String>();
2347                                parts.push(format!("[error] {}", err_text));
2348                            }
2349                        } else if btype == "text" {
2350                            if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
2351                                let trimmed = text.trim();
2352                                if !trimmed.is_empty() {
2353                                    parts.push(format!("[user] {}", trimmed));
2354                                }
2355                            }
2356                        }
2357                    }
2358                }
2359            }
2360            "result" => {
2361                if let Some(cost) = val.get("total_cost_usd").and_then(|v| v.as_f64()) {
2362                    if let Some(turns) = val.get("num_turns").and_then(|v| v.as_u64()) {
2363                        parts.push(format!("[summary] {} turns, ${:.4} total cost", turns, cost));
2364                    }
2365                }
2366            }
2367            // Skip: system, stream_event (deltas), rate_limit_event
2368            _ => {}
2369        }
2370    }
2371
2372    parts.join("\n")
2373}
2374
2375/// Invoke the Claude CLI with a prompt and extract the text response.
2376/// Uses `-p --output-format stream-json --verbose` (non-interactive mode).
2377async fn invoke_cli_for_digest(
2378    cli_path: &str,
2379    prompt: &str,
2380    meta: &obj::MetaMapType,
2381) -> Result<String, String> {
2382    // Inherit auth env from block meta (CLAUDE_CONFIG_DIR, etc.)
2383    let auth_env: std::collections::HashMap<String, String> = match meta.get("cmd:env") {
2384        Some(serde_json::Value::Object(obj_map)) => obj_map
2385            .iter()
2386            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2387            .collect(),
2388        _ => std::collections::HashMap::new(),
2389    };
2390
2391    // Pipe the prompt via stdin rather than passing it as a CLI arg — Linux
2392    // caps individual argv entries at MAX_ARG_STRLEN (~128 KB), and a digest
2393    // over 200 lines of session content can easily exceed that.
2394    // `kill_on_drop(true)` ensures the child is terminated if the timeout
2395    // future below is dropped — tokio `Child` does NOT kill on drop by default.
2396    let mut child = crate::server::cli_handlers::make_cli_cmd(cli_path)
2397        .args(["-p", "--output-format", "stream-json", "--verbose"])
2398        .envs(&auth_env)
2399        .stdin(std::process::Stdio::piped())
2400        .stdout(std::process::Stdio::piped())
2401        .stderr(std::process::Stdio::null())
2402        .kill_on_drop(true)
2403        .spawn()
2404        .map_err(|e| format!("failed to spawn digest CLI: {e}"))?;
2405
2406    if let Some(mut stdin) = child.stdin.take() {
2407        use tokio::io::AsyncWriteExt;
2408        stdin
2409            .write_all(prompt.as_bytes())
2410            .await
2411            .map_err(|e| format!("digest CLI stdin write: {e}"))?;
2412        stdin
2413            .shutdown()
2414            .await
2415            .map_err(|e| format!("digest CLI stdin shutdown: {e}"))?;
2416    }
2417
2418    let output = tokio::time::timeout(
2419        std::time::Duration::from_secs(60),
2420        child.wait_with_output(),
2421    )
2422    .await
2423    .map_err(|_| "digest CLI timed out after 60s".to_string())?
2424    .map_err(|e| format!("digest CLI wait: {e}"))?;
2425
2426    if !output.status.success() {
2427        return Err(format!("digest CLI exited with status {}", output.status));
2428    }
2429
2430    // Parse stream-json output — capture the last assistant text block
2431    let stdout = String::from_utf8_lossy(&output.stdout);
2432    let mut last_text = String::new();
2433
2434    for line in stdout.lines() {
2435        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else { continue };
2436        let msg_type = val.get("type").and_then(|v| v.as_str()).unwrap_or("");
2437        if msg_type == "assistant" {
2438            if let Some(content) = val.get("message")
2439                .and_then(|m| m.get("content"))
2440                .and_then(|c| c.as_array())
2441            {
2442                for block in content {
2443                    if block.get("type").and_then(|v| v.as_str()) == Some("text") {
2444                        if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
2445                            last_text = text.trim().to_string();
2446                        }
2447                    }
2448                }
2449            }
2450        }
2451    }
2452
2453    if last_text.is_empty() {
2454        return Err("no text content in digest CLI response".to_string());
2455    }
2456
2457    Ok(last_text)
2458}
2459
2460// ---------------------------------------------------------------------------
2461// Helpers
2462// ---------------------------------------------------------------------------
2463
2464/// Resolve a tab ID: use the provided one, or fall back to the first workspace's active tab.
2465fn resolve_tab_id(wstore: &Store, explicit: Option<&str>) -> Result<String, String> {
2466    if let Some(tid) = explicit {
2467        return Ok(tid.to_string());
2468    }
2469
2470    // Fall back to first workspace's active tab
2471    let workspaces: Vec<Workspace> = wstore.get_all::<Workspace>()
2472        .map_err(|e| format!("agent.open: list workspaces: {e}"))?;
2473
2474    for ws in &workspaces {
2475        if !ws.activetabid.is_empty() {
2476            return Ok(ws.activetabid.clone());
2477        }
2478        if let Some(first_tab) = ws.tabids.first() {
2479            return Ok(first_tab.clone());
2480        }
2481    }
2482
2483    Err("no tabs found in any workspace".to_string())
2484}
2485
2486/// Find an existing agent block in a tab by agent ID.
2487fn find_agent_block(wstore: &Store, tab_id: &str, agent_id: &str) -> Result<Option<Block>, String> {
2488    let tab: Tab = wstore.must_get(tab_id)
2489        .map_err(|e| format!("TAB_NOT_FOUND: {e}"))?;
2490
2491    for block_id in &tab.blockids {
2492        if let Ok(Some(block)) = wstore.get::<Block>(block_id) {
2493            let block_agent_id = obj::meta_get_string(&block.meta, "agentId", "");
2494            if block_agent_id == agent_id {
2495                return Ok(Some(block));
2496            }
2497        }
2498    }
2499    Ok(None)
2500}
2501
2502/// Atomically allocate an agent working directory.
2503///
2504/// Tries to atomically create `desired` via `std::fs::create_dir`. If
2505/// that fails because the directory already exists, tries `<desired>-1`,
2506/// `<desired>-2`, …, up to `-99`. The atomic `create_dir` (NOT
2507/// `create_dir_all` for the leaf) is the reservation mechanism: two
2508/// concurrent callers competing for the same path race on the OS
2509/// `mkdir` syscall and one wins; the loser sees `AlreadyExists` and
2510/// moves on.
2511///
2512/// Caller is responsible for distinguishing auto-generated paths from
2513/// user-specified ones — this function rewrites the path on collision,
2514/// which would clobber a user's intent if they pointed an agent at
2515/// `~/projects/myrepo` and that already had a `CLAUDE.md`.
2516pub fn allocate_agent_workdir(desired: &str) -> Result<String, String> {
2517    let p = std::path::Path::new(desired);
2518    if let Some(parent) = p.parent() {
2519        if !parent.as_os_str().is_empty() {
2520            std::fs::create_dir_all(parent)
2521                .map_err(|e| format!("allocate_agent_workdir: parent {}: {e}", parent.display()))?;
2522        }
2523    }
2524    match std::fs::create_dir(p) {
2525        Ok(()) => return Ok(desired.to_string()),
2526        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
2527        Err(e) => return Err(format!("allocate_agent_workdir: create_dir({}): {e}", desired)),
2528    }
2529    for n in 1..=99u32 {
2530        let candidate = format!("{desired}-{n}");
2531        match std::fs::create_dir(std::path::Path::new(&candidate)) {
2532            Ok(()) => return Ok(candidate),
2533            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
2534            Err(e) => return Err(format!("allocate_agent_workdir: create_dir({candidate}): {e}")),
2535        }
2536    }
2537    Err(format!(
2538        "allocate_agent_workdir: too many collisions (>99) under {desired}-N — clean up old runs"
2539    ))
2540}
2541
2542/// Write agent config files (CLAUDE.md, .mcp.json, etc.) to the working directory.
2543fn write_agent_config_files(
2544    wstore: &Store,
2545    agent: &crate::backend::storage::AgentDefinition,
2546    agent_slug: &str,
2547    work_dir: &str,
2548) -> Result<(), String> {
2549    // Load agent content and skills
2550    let contents = wstore.agent_content_get_all(&agent.id)
2551        .unwrap_or_default();
2552    let skills = wstore.agent_skill_list(&agent.id)
2553        .unwrap_or_default();
2554
2555    let mut content_map = std::collections::HashMap::new();
2556    for fc in &contents {
2557        content_map.insert(fc.content_type.clone(), fc.content.clone());
2558    }
2559
2560    // Disable autonomous memory writes only when using the bare slug-fallback workdir
2561    // (~/.agentmux/agents/<slug>) — that path is shared across same-name multi-tab
2562    // launches with no collision resolution, risking concurrent MEMORY.md corruption
2563    // (upstream issue #29051). An empty working_directory field means the caller
2564    // chose the fallback; any explicitly set workdir is isolated and keeps writes on.
2565    let workdir_is_shared = agent.working_directory.is_empty();
2566    if workdir_is_shared {
2567        let settings_str = content_map
2568            .entry("settings".to_string())
2569            .or_insert_with(|| "{}".to_string());
2570        match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(settings_str) {
2571            Ok(mut obj) => {
2572                obj.entry("autoMemoryEnabled".to_string())
2573                    .or_insert(json!(false));
2574                *settings_str =
2575                    serde_json::to_string(&obj).unwrap_or_else(|_| "{}".to_string());
2576            }
2577            Err(e) => {
2578                tracing::warn!(
2579                    work_dir = %work_dir,
2580                    error = %e,
2581                    "write_agent_config_files: settings JSON unparseable; \
2582                     autoMemoryEnabled guard skipped — memory writes may be active on shared workdir"
2583                );
2584            }
2585        }
2586    }
2587
2588    // Inject global memory bundles (Trust Center global brain) into CLAUDE.md.
2589    // All agents get these regardless of per-agent memory selection. Each
2590    // section carries a `# [Workspace] <name>` heading (see
2591    // format_global_brain_block) so the rules are attributable to the
2592    // workspace and ordered per the Brain tab's sort_order.
2593    let global_bundles = wstore.bundle_memory_list_global().unwrap_or_default();
2594    let global_block = crate::backend::storage::format_global_brain_block(&global_bundles);
2595    if !global_block.is_empty() {
2596        content_map
2597            .entry("memory".to_string())
2598            .and_modify(|existing| {
2599                *existing = format!("{global_block}\n\n---\n\n{existing}");
2600            })
2601            .or_insert(global_block);
2602    }
2603
2604    let config_files = crate::backend::agent_config::build_config_files(
2605        &content_map,
2606        &skills,
2607        &agent.name,
2608        &agent.id,
2609        agent_slug,
2610    );
2611
2612    // Expand ~ in work_dir
2613    let expanded_dir = if work_dir.starts_with("~/") || work_dir == "~" {
2614        if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
2615            format!("{}/{}", home, work_dir.trim_start_matches("~/"))
2616        } else {
2617            work_dir.to_string()
2618        }
2619    } else {
2620        work_dir.to_string()
2621    };
2622
2623    let base_path = std::path::Path::new(&expanded_dir);
2624    if !base_path.exists() {
2625        std::fs::create_dir_all(base_path)
2626            .map_err(|e| format!("failed to create working dir: {e}"))?;
2627    }
2628
2629    for file in &config_files {
2630        let file_path = base_path.join(&file.filename);
2631        if let Some(parent) = file_path.parent() {
2632            if !parent.exists() {
2633                let _ = std::fs::create_dir_all(parent);
2634            }
2635        }
2636        std::fs::write(&file_path, &file.content)
2637            .map_err(|e| format!("failed to write {}: {e}", file.filename))?;
2638    }
2639
2640    tracing::info!(
2641        agent_id = %agent.id,
2642        work_dir = %expanded_dir,
2643        file_count = config_files.len(),
2644        "agent.open: wrote config files"
2645    );
2646
2647    Ok(())
2648}
2649
2650// ---------------------------------------------------------------------------
2651// agent.define helpers
2652// ---------------------------------------------------------------------------
2653
2654/// Create a stub AgentInstance for a definition, idempotently.
2655///
2656/// The stub id is deterministically derived from the definition id so that
2657/// calling this function multiple times for the same definition is a no-op
2658/// rather than accumulating duplicate stopped rows in My Agents.
2659/// Infer a provider slug from a model name prefix.
2660/// Only maps prefixes that correspond to a registered provider slug.
2661/// Callers must still validate the result via `providers::get_provider`.
2662fn infer_provider_from_model(model: &str) -> String {
2663    let m = model.to_lowercase();
2664    if m.starts_with("claude") {
2665        "claude".to_string()
2666    } else if m.starts_with("gemini") {
2667        "gemini".to_string()
2668    } else if m.starts_with("codex") {
2669        "codex".to_string()
2670    } else if m.starts_with("qwen") {
2671        "qwen".to_string()
2672    } else if m.starts_with("kimi") {
2673        "kimi".to_string()
2674    } else {
2675        // Unknown prefix — return as-is; get_provider will reject it with
2676        // a "cannot infer provider" error so callers know to set provider explicitly.
2677        model.to_string()
2678    }
2679}
2680
2681/// Returns `(stub_id, newly_inserted)`. `newly_inserted = false` when the
2682/// UNIQUE constraint fires (stub already existed); callers use this to avoid
2683/// broadcasting `agents:changed` on no-op calls.
2684fn make_stub_idempotent(
2685    wstore: &crate::backend::storage::store::Store,
2686    def_id: &str,
2687    name: &str,
2688    now: i64,
2689) -> Result<(String, bool), String> {
2690    let stub_id = format!("si-{}", def_id.replace('-', ""));
2691    let inst = AgentInstance {
2692        id: stub_id.clone(),
2693        definition_id: def_id.to_string(),
2694        parent_instance_id: String::new(),
2695        block_id: String::new(),
2696        session_id: String::new(),
2697        status: "stopped".to_string(),
2698        github_context: String::new(),
2699        started_at: now,
2700        ended_at: 0,
2701        created_at: now,
2702        identity_id: String::new(),
2703        memory_id: String::new(),
2704        instance_name: name.to_string(),
2705        working_directory: String::new(),
2706        display_hidden: false,
2707    };
2708    match wstore.instance_create(&inst) {
2709        Ok(_) => Ok((stub_id, true)),
2710        Err(e) if e.to_string().contains("UNIQUE constraint") => {
2711            Ok((stub_id, false)) // stub already existed — idempotent
2712        }
2713        Err(e) => Err(format!("agent.define: create stub instance: {e}")),
2714    }
2715}
2716
2717// ---------------------------------------------------------------------------
2718// agent.define
2719// ---------------------------------------------------------------------------
2720
2721/// Core logic for the `agent.define` command, shared by the WebSocket RPC
2722/// handler and the HTTP service dispatch (`("agent", "define")` in service.rs).
2723/// Persist `system_prompt` and `env` content blobs for a freshly created or
2724/// updated agent definition.  Errors are logged but not propagated — the
2725/// definition row is already committed and the caller has already published
2726/// `agents:changed`, so a content-write failure must not abort the response.
2727fn persist_define_content(
2728    wstore: &Store,
2729    agent_id: &str,
2730    cmd: &CommandAgentDefineData,
2731    now: i64,
2732) {
2733    if let Some(prompt) = &cmd.system_prompt {
2734        if !prompt.is_empty() {
2735            if let Err(e) = wstore.agent_content_set(&AgentContent {
2736                agent_id: agent_id.to_string(),
2737                content_type: "agentmd".to_string(),
2738                content: prompt.clone(),
2739                updated_at: now,
2740            }) {
2741                tracing::warn!(agent_id, err = %e, "agent.define: failed to persist system_prompt (non-fatal)");
2742            }
2743        }
2744    }
2745    if let Some(env_map) = &cmd.env {
2746        if !env_map.is_empty() {
2747            let content = env_map.iter()
2748                .map(|(k, v)| format!("{}={}", k, v))
2749                .collect::<Vec<_>>()
2750                .join("\n");
2751            if let Err(e) = wstore.agent_content_set(&AgentContent {
2752                agent_id: agent_id.to_string(),
2753                content_type: "env".to_string(),
2754                content,
2755                updated_at: now,
2756            }) {
2757                tracing::warn!(agent_id, err = %e, "agent.define: failed to persist env (non-fatal)");
2758            }
2759        }
2760    }
2761}
2762
2763pub(crate) async fn agent_define_core(
2764    wstore: Arc<Store>,
2765    broker: Arc<crate::backend::wps::Broker>,
2766    cmd: CommandAgentDefineData,
2767) -> Result<AgentDefineResult, String> {
2768    if cmd.name.trim().is_empty() {
2769        return Err("agent.define: name is required".to_string());
2770    }
2771
2772    // Validate if_exists early so a typo is caught even for new definitions,
2773    // not only when a matching definition already exists.
2774    let if_exists = cmd.if_exists.as_deref().unwrap_or("skip");
2775    if !matches!(if_exists, "skip" | "update" | "error") {
2776        return Err(format!(
2777            "agent.define: unknown if_exists value '{if_exists}'; valid: skip, update, error"
2778        ));
2779    }
2780
2781    // Resolve provider: explicit `provider` wins; fall back to inference from
2782    // `model` prefix; default to "claude" when neither is supplied.
2783    let provider = if !cmd.provider.is_empty() {
2784        if providers::get_provider(&cmd.provider).is_none() {
2785            return Err(format!(
2786                "agent.define: unknown provider '{}'; valid: claude, codex, gemini, qwen, kimi, openclaw, pi, copilot",
2787                cmd.provider
2788            ));
2789        }
2790        cmd.provider.clone()
2791    } else if !cmd.model.is_empty() {
2792        let inferred = infer_provider_from_model(&cmd.model);
2793        if providers::get_provider(&inferred).is_none() {
2794            return Err(format!(
2795                "agent.define: cannot infer provider from model '{}'; set provider explicitly",
2796                cmd.model
2797            ));
2798        }
2799        inferred
2800    } else {
2801        "claude".to_string()
2802    };
2803
2804    let create_stub = cmd.create_instance_stub.unwrap_or(true);
2805
2806    let now = std::time::SystemTime::now()
2807        .duration_since(std::time::UNIX_EPOCH)
2808        .unwrap_or_default()
2809        .as_millis() as i64;
2810
2811    // Build the new definition struct up-front so agent_def_find_or_insert
2812    // can use it as both the lookup key and the insert payload.
2813    // agent_def_find_or_insert holds a single mutex guard for the check +
2814    // conditional insert — closing the TOCTOU window between list and insert.
2815    let mut def = AgentDefinition {
2816        id: uuid::Uuid::new_v4().to_string(),
2817        slug: String::new(), // resolved by agent_def_find_or_insert
2818        name: cmd.name.clone(),
2819        icon: cmd.icon.clone(),
2820        provider: provider.clone(),
2821        description: cmd.description.clone(),
2822        working_directory: cmd.working_directory.clone(),
2823        shell: cmd.shell.clone(),
2824        environment: cmd.environment.clone(),
2825        // Persist the requested model as a CLI flag so the agent launches
2826        // with the specified model rather than the provider default.
2827        provider_flags: if cmd.model.is_empty() {
2828            String::new()
2829        } else {
2830            format!("--model {}", cmd.model)
2831        },
2832        auto_start: 0,
2833        restart_on_crash: 0,
2834        idle_timeout_minutes: 0,
2835        created_at: now,
2836        agent_type: cmd.agent_type.clone(),
2837        agent_bus_id: String::new(),
2838        is_seeded: 0,
2839        accounts: String::new(),
2840        parent_id: String::new(),
2841        branch_label: String::new(),
2842        updated_at: now,
2843        user_hidden: 0,
2844        container_image: cmd.container_image.clone(),
2845        container_volumes: cmd.container_volumes.clone(),
2846        container_name: String::new(), // assigned by ContainerManager on first spawn
2847    };
2848
2849    // Atomic check-then-insert.
2850    // Returns Some(existing) if a row matched by name/slug already exists;
2851    // None if the row was freshly inserted (def.slug now holds resolved slug).
2852    let existing_opt = wstore.agent_def_find_or_insert(&mut def)
2853        .map_err(|e| format!("agent.define: find_or_insert: {e}"))?;
2854
2855    if let Some(existing) = existing_opt {
2856        // A definition with this name/slug already exists — apply if_exists policy.
2857        match if_exists {
2858            "skip" => {
2859                // Honor create_instance_stub even on skip: a definition that was
2860                // created with create_instance_stub=false (or imported via another
2861                // path) might not have a stub yet; a subsequent idempotent call
2862                // with create_instance_stub=true should make it visible in My Agents.
2863                // Only fire agents:changed when the stub was actually newly inserted.
2864                let (stub_id, stub_new) = if create_stub {
2865                    match make_stub_idempotent(&wstore, &existing.id, &existing.name, now) {
2866                        Ok((id, new)) => (Some(id), new),
2867                        Err(e) => {
2868                            tracing::warn!(id = %existing.id, err = %e, "agent.define: skip stub failed (non-fatal)");
2869                            (None, false)
2870                        }
2871                    }
2872                } else {
2873                    (None, false)
2874                };
2875                if stub_new {
2876                    broker.publish(crate::backend::wps::WaveEvent {
2877                        event: "agents:changed".to_string(),
2878                        scopes: vec![],
2879                        sender: String::new(),
2880                        persist: 0,
2881                        data: None,
2882                    });
2883                }
2884                tracing::info!(id = %existing.id, slug = %existing.slug, stub = stub_id.is_some(), "agent.define: skipped (exists)");
2885                return Ok(AgentDefineResult {
2886                    definition_id: existing.id.clone(),
2887                    slug: existing.slug.clone(),
2888                    action: "skipped".to_string(),
2889                    instance_stub_id: stub_id,
2890                });
2891            }
2892            "error" => {
2893                return Err(format!(
2894                    "agent.define: definition '{}' already exists (if_exists=error)",
2895                    cmd.name.trim()
2896                ));
2897            }
2898            "update" => {
2899                let mut updated = existing.clone();
2900                // provider was already validated/defaulted above; only
2901                // overwrite if the caller explicitly supplied a provider or model.
2902                if !cmd.provider.is_empty() || !cmd.model.is_empty() { updated.provider = provider.clone(); }
2903                // Persist the model as a CLI flag so the agent launches with
2904                // the requested model rather than the provider default.
2905                // If the provider changes but no model is supplied, clear stale
2906                // flags from the old provider so the new provider's default is used.
2907                if !cmd.model.is_empty() {
2908                    updated.provider_flags = format!("--model {}", cmd.model);
2909                } else if !cmd.provider.is_empty() {
2910                    updated.provider_flags = String::new();
2911                }
2912                if !cmd.icon.is_empty()     { updated.icon = cmd.icon.clone(); }
2913                if !cmd.description.is_empty() { updated.description = cmd.description.clone(); }
2914                if !cmd.working_directory.is_empty() { updated.working_directory = cmd.working_directory.clone(); }
2915                if !cmd.shell.is_empty()    { updated.shell = cmd.shell.clone(); }
2916                if !cmd.environment.is_empty() { updated.environment = cmd.environment.clone(); }
2917                // name update intentionally omitted — the slug is immutable;
2918                // renaming would create a slug mismatch. Use updateagent for renames.
2919                let did_update = wstore.agent_def_update(&mut updated)
2920                    .map_err(|e| format!("agent.define: update: {e}"))?;
2921                if !did_update {
2922                    return Err("agent.define: update: row was deleted between find and update".to_string());
2923                }
2924                persist_define_content(&wstore, &updated.id, &cmd, now);
2925                let stub_id = if create_stub {
2926                    match make_stub_idempotent(&wstore, &updated.id, &updated.name, now) {
2927                        Ok((id, _new)) => Some(id),
2928                        Err(e) => {
2929                            tracing::warn!(id = %updated.id, err = %e, "agent.define: update stub failed (non-fatal)");
2930                            None
2931                        }
2932                    }
2933                } else {
2934                    None
2935                };
2936                broker.publish(crate::backend::wps::WaveEvent {
2937                    event: "agents:changed".to_string(),
2938                    scopes: vec![],
2939                    sender: String::new(),
2940                    persist: 0,
2941                    data: None,
2942                });
2943                tracing::info!(id = %updated.id, slug = %updated.slug, stub = stub_id.is_some(), "agent.define: updated");
2944                return Ok(AgentDefineResult {
2945                    definition_id: updated.id.clone(),
2946                    slug: updated.slug.clone(),
2947                    action: "updated".to_string(),
2948                    instance_stub_id: stub_id,
2949                });
2950            }
2951            other => {
2952                return Err(format!("agent.define: unknown if_exists value '{other}'"));
2953            }
2954        }
2955    }
2956
2957    // Fresh insert — def.slug is now set by agent_def_find_or_insert.
2958    // Create the stub first so that listeners handling agents:changed can
2959    // immediately find the new agent via ListRecentSessionsCommand. The
2960    // definition is already committed; a stub failure is non-fatal (log +
2961    // continue) and we still broadcast so callers see the new definition.
2962    let stub_id = if create_stub {
2963        match make_stub_idempotent(&wstore, &def.id, &def.name, now) {
2964            Ok((id, _new)) => Some(id),
2965            Err(e) => {
2966                tracing::warn!(id = %def.id, err = %e, "agent.define: stub failed (definition committed, non-fatal)");
2967                None
2968            }
2969        }
2970    } else {
2971        None
2972    };
2973    broker.publish(crate::backend::wps::WaveEvent {
2974        event: "agents:changed".to_string(),
2975        scopes: vec![],
2976        sender: String::new(),
2977        persist: 0,
2978        data: None,
2979    });
2980    persist_define_content(&wstore, &def.id, &cmd, now);
2981
2982    tracing::info!(
2983        id = %def.id,
2984        slug = %def.slug,
2985        stub = stub_id.is_some(),
2986        "agent.define: created"
2987    );
2988
2989    Ok(AgentDefineResult {
2990        definition_id: def.id.clone(),
2991        slug: def.slug.clone(),
2992        action: "created".to_string(),
2993        instance_stub_id: stub_id,
2994    })
2995}
2996
2997fn register_agent_define(engine: &Arc<WshRpcEngine>, state: &AppState) {
2998    let wstore = state.wstore.clone();
2999    let broker = state.broker.clone();
3000
3001    engine.register_handler(
3002        COMMAND_AGENT_DEFINE,
3003        Box::new(move |data, _ctx| {
3004            let wstore = wstore.clone();
3005            let broker = broker.clone();
3006            Box::pin(async move {
3007                let cmd: CommandAgentDefineData = serde_json::from_value(data)
3008                    .map_err(|e| format!("agent.define: {e}"))?;
3009                agent_define_core(wstore, broker, cmd).await
3010                    .map(|r| Some(serde_json::to_value(&r).unwrap()))
3011            })
3012        }),
3013    );
3014}
3015
3016#[cfg(test)]
3017mod cross_channel_tests {
3018    use super::*;
3019    use crate::backend::agent_session::OUTPUT_FILE;
3020    use crate::backend::storage::filestore::{FileMeta, FileOpts, FileStore};
3021
3022    fn mem_store() -> Arc<FileStore> {
3023        Arc::new(FileStore::open_in_memory().unwrap())
3024    }
3025
3026    fn seed_output(fs: &Arc<FileStore>, zone: &str, body: &[u8]) {
3027        fs.make_file(zone, OUTPUT_FILE, FileMeta::default(), FileOpts::default())
3028            .unwrap();
3029        fs.append_data(zone, OUTPUT_FILE, body).unwrap();
3030    }
3031
3032    fn insert_agent_block(wstore: &Arc<Store>, def_id: &str) -> String {
3033        let oid = uuid::Uuid::new_v4().to_string();
3034        let mut meta = MetaMapType::new();
3035        meta.insert("view".to_string(), serde_json::json!("agent"));
3036        meta.insert("agentId".to_string(), serde_json::json!(def_id));
3037        let mut block = Block {
3038            oid: oid.clone(),
3039            parentoref: String::new(),
3040            version: 1,
3041            runtimeopts: None,
3042            stickers: None,
3043            meta,
3044            subblockids: None,
3045        };
3046        wstore.insert(&mut block).expect("insert block");
3047        oid
3048    }
3049
3050    #[test]
3051    fn global_output_source_falls_back_when_local_empty() {
3052        let per_channel = mem_store();
3053        let global = mem_store();
3054        let wstore = Arc::new(Store::open_in_memory().unwrap());
3055        let block_id = insert_agent_block(&wstore, "def-cc-1");
3056
3057        // No local output for the block, but the global zone has content.
3058        seed_output(&global, "agent:def-cc-1:current", b"{\"type\":\"user\"}\n");
3059
3060        let resolved = global_output_source(
3061            &per_channel,
3062            &Some(global.clone()),
3063            &wstore,
3064            &block_id,
3065            "output",
3066        );
3067        let (_store, zone) = resolved.expect("should fall back to global");
3068        assert_eq!(zone, "agent:def-cc-1:current");
3069    }
3070
3071    #[test]
3072    fn global_output_source_prefers_global_even_when_local_present() {
3073        // After the Bug-1 fix: even when the local output is non-empty (current
3074        // session started writing), the global zone is still returned so that
3075        // cross-channel history load sees the FULL record, not just the current
3076        // session's lines.
3077        let per_channel = mem_store();
3078        let global = mem_store();
3079        let wstore = Arc::new(Store::open_in_memory().unwrap());
3080        let block_id = insert_agent_block(&wstore, "def-cc-2");
3081
3082        seed_output(&per_channel, &block_id, b"{\"type\":\"local\"}\n");
3083        seed_output(&global, "agent:def-cc-2:current", b"{\"type\":\"global\"}\n");
3084
3085        let resolved = global_output_source(
3086            &per_channel,
3087            &Some(global.clone()),
3088            &wstore,
3089            &block_id,
3090            "output",
3091        );
3092        let (_, zone) = resolved.expect("global always preferred when available");
3093        assert_eq!(zone, "agent:def-cc-2:current");
3094    }
3095
3096    #[test]
3097    fn global_output_source_only_for_output_and_with_global_store() {
3098        let per_channel = mem_store();
3099        let global = mem_store();
3100        let wstore = Arc::new(Store::open_in_memory().unwrap());
3101        let block_id = insert_agent_block(&wstore, "def-cc-3");
3102        seed_output(&global, "agent:def-cc-3:current", b"{\"x\":1}\n");
3103
3104        // Non-"output" filename is never globalized.
3105        assert!(global_output_source(&per_channel, &Some(global.clone()), &wstore, &block_id, "term").is_none());
3106        // No global store configured → None.
3107        assert!(global_output_source(&per_channel, &None, &wstore, &block_id, "output").is_none());
3108        // Non-agent block id → None.
3109        assert!(global_output_source(&per_channel, &Some(global), &wstore, "not-a-block", "output").is_none());
3110    }
3111
3112    #[test]
3113    fn global_output_source_suppressed_for_archived_block() {
3114        // A block archived via the UI/sweep (`session:archived_at` set, local
3115        // output deleted) must NOT resurrect from the global mirror — it should
3116        // reopen archived/empty as pre-PR. (reagent P1 #1399.)
3117        let per_channel = mem_store();
3118        let global = mem_store();
3119        let wstore = Arc::new(Store::open_in_memory().unwrap());
3120
3121        let oid = uuid::Uuid::new_v4().to_string();
3122        let mut meta = MetaMapType::new();
3123        meta.insert("view".to_string(), serde_json::json!("agent"));
3124        meta.insert("agentId".to_string(), serde_json::json!("def-cc-arch"));
3125        meta.insert(
3126            crate::backend::session_archive::META_SESSION_ARCHIVED_AT.to_string(),
3127            serde_json::json!(1_700_000_000_000i64),
3128        );
3129        let mut block = Block {
3130            oid: oid.clone(),
3131            parentoref: String::new(),
3132            version: 1,
3133            runtimeopts: None,
3134            stickers: None,
3135            meta,
3136            subblockids: None,
3137        };
3138        wstore.insert(&mut block).expect("insert block");
3139
3140        // Global zone has content, but the block is archived → no fallback.
3141        seed_output(&global, "agent:def-cc-arch:current", b"{\"type\":\"user\"}\n");
3142        assert!(
3143            global_output_source(&per_channel, &Some(global), &wstore, &oid, "output").is_none(),
3144            "archived block must not fall back to the global mirror",
3145        );
3146    }
3147
3148    #[test]
3149    fn global_zone_line_count_counts_non_blank_lines() {
3150        let global = mem_store();
3151        let zone = "agent:def-cc-4:current";
3152        seed_output(&global, zone, b"{\"a\":1}\n{\"b\":2}\n\n{\"c\":3}\n");
3153        // 3 non-blank NDJSON lines (the blank line is ignored, matching read_range).
3154        assert_eq!(global_zone_line_count(&global, zone), Some(3));
3155
3156        // Empty / absent zone → Some(0) / None respectively.
3157        let empty_zone = "agent:def-empty:current";
3158        assert_eq!(global_zone_line_count(&global, empty_zone), None);
3159    }
3160}
3161
3162
3163#[cfg(test)]
3164mod pane_open_reducer_tests {
3165    use super::*;
3166    use crate::backend::rpc_types::CommandPaneOpenData;
3167    use crate::server::tests::test_state;
3168    use agentmux_common::ipc::{Command, Event};
3169
3170    async fn dispatch_apply(state: &AppState, cmd: Command) -> Vec<Event> {
3171        let evs = crate::server::service::dispatch_to_reducer(state, cmd).await;
3172        for ev in &evs {
3173            crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
3174        }
3175        evs
3176    }
3177
3178    /// Regression for #1681: the docked `pane.open` path created its block
3179    /// store-only (`wcore::create_block`), so the block was absent from the
3180    /// reducer's `state.blocks` and a later TearOffBlock / RedockFloatingPane
3181    /// was rejected "block not found". Assert the block now lands in `srv_state`
3182    /// and a tear-off of the freshly-opened pane succeeds end-to-end.
3183    #[tokio::test]
3184    async fn docked_pane_open_block_is_in_reducer_and_tears_off() {
3185        let state = test_state();
3186
3187        // Workspace + tab through the reducer (→ srv_state AND, via apply, wstore).
3188        let ws_evs = dispatch_apply(&state, Command::CreateWorkspace { name: "w".into() }).await;
3189        let ws_id = ws_evs
3190            .iter()
3191            .find_map(|e| match e {
3192                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
3193                _ => None,
3194            })
3195            .unwrap();
3196        let tab_evs = dispatch_apply(
3197            &state,
3198            Command::CreateTab { workspace_id: ws_id.clone(), name: "t".into() },
3199        )
3200        .await;
3201        let tab_id = tab_evs
3202            .iter()
3203            .find_map(|e| match e {
3204                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
3205                _ => None,
3206            })
3207            .unwrap();
3208
3209        // Open a docked sysinfo pane (no required args).
3210        let cmd = CommandPaneOpenData {
3211            view: "sysinfo".into(),
3212            file: None,
3213            url: None,
3214            cwd: None,
3215            title: None,
3216            tab_id: Some(tab_id.clone()),
3217            split_direction: None,
3218            split_reference_block_id: None,
3219            focus: None,
3220            tree_expanded: None,
3221            floating: None,
3222        };
3223        let res = open_pane(&state, cmd).await.expect("open_pane docked");
3224
3225        // The block is now visible to the reducer (was the bug: store-only).
3226        {
3227            let s = state.srv_state.lock().await;
3228            assert!(
3229                s.blocks.contains_key(&res.block_id),
3230                "docked pane.open block must be tracked in srv_state"
3231            );
3232        }
3233
3234        // And tearing it off no longer hits "block not found".
3235        let r = crate::sagas::tear_off_block::run(
3236            &state,
3237            res.block_id.clone(),
3238            tab_id.clone(),
3239            ws_id.clone(),
3240        )
3241        .await;
3242        assert!(r.is_ok(), "tear-off of an opened pane must succeed, got: {:?}", r.err());
3243    }
3244}
3245
3246/// Parse + validate a saved per-agent `ui:zoom` content blob for seeding a new
3247/// agent block's `term:zoom`. Returns `Some(z)` only for a parseable,
3248/// non-default (≠ 1.0), in-[0.5, 2.0] zoom (the range the frontend enforces in
3249/// term.tsx); anything else (default, out of range, garbage) returns `None` so
3250/// the new block opens at the default 1.0. See SPEC_AGENT_ZOOM_PERSISTENCE §4.2.
3251fn parse_seed_zoom(raw: &str) -> Option<f64> {
3252    let z = raw.trim().parse::<f64>().ok()?;
3253    if (z - 1.0).abs() > f64::EPSILON && (0.5..=2.0).contains(&z) {
3254        Some(z)
3255    } else {
3256        None
3257    }
3258}
3259
3260#[cfg(test)]
3261mod agent_zoom_seed_tests {
3262    use super::parse_seed_zoom;
3263
3264    #[test]
3265    fn seeds_valid_non_default_zoom() {
3266        assert_eq!(parse_seed_zoom("1.3"), Some(1.3));
3267        assert_eq!(parse_seed_zoom("0.5"), Some(0.5));
3268        assert_eq!(parse_seed_zoom("2"), Some(2.0));
3269        assert_eq!(parse_seed_zoom("  1.4  "), Some(1.4)); // trims
3270    }
3271
3272    #[test]
3273    fn rejects_default_out_of_range_and_garbage() {
3274        assert_eq!(parse_seed_zoom("1.0"), None, "default seeds nothing");
3275        assert_eq!(parse_seed_zoom("1"), None, "default seeds nothing");
3276        assert_eq!(parse_seed_zoom("2.5"), None, "above range");
3277        assert_eq!(parse_seed_zoom("0.4"), None, "below range");
3278        assert_eq!(parse_seed_zoom("abc"), None, "unparseable");
3279        assert_eq!(parse_seed_zoom(""), None, "empty");
3280    }
3281}