1use 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
26pub 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
51fn 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
148fn 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 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 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 let provider = providers::get_provider(&agent.provider)
185 .ok_or_else(|| format!("INVALID_PROVIDER: unknown provider '{}'", agent.provider))?;
186
187 let tab_id = resolve_tab_id(&wstore, cmd.tab_id.as_deref())?;
189
190 if let Some(existing) = find_agent_block(&wstore, &tab_id, &agent.id)? {
193 if blockcontroller::get_controller(&existing.oid).is_none() {
196 let controller_type = provider.controller_type_str();
197 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 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 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 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 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 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 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 let config_home = std::env::var("AGENTMUX_CONFIG_HOME")
288 .unwrap_or_else(|_| format!("{}/.agentmux/config", home));
289 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 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 env_vars.insert("GH_CONFIG_DIR".to_string(), json!(format!("{}/gh-{}", config_home, agent_slug)));
317 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 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 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 meta.insert("agent:container_command".to_string(), json!(provider.cli_command));
357 let output_format = match provider.id {
359 "claude" => "claude-stream-json",
360 "codex" => "codex-json",
361 "gemini" => "gemini-json",
362 "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 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 {
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 write_agent_config_files(&wstore, &agent, routing_id, &work_dir)?;
455
456 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 {
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
525fn 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 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 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 let mut session_id = None;
590 if let Some(persistent_ctrl) = ctrl
591 .as_any()
592 .downcast_ref::<blockcontroller::persistent::PersistentSubprocessController>()
593 {
594 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 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 let persisted_session_id = obj::meta_get_string(
634 &block.meta, "agent:sessionid", "",
635 );
636
637 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 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 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 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(), cli_args: vec![], working_dir: String::new(), 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 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
732fn 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
765fn 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; 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
817fn 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
866fn 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 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, );
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
929fn 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
949pub 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 let meta = build_pane_meta(&cmd)?;
962
963 let tab_id = resolve_tab_id(&wstore, cmd.tab_id.as_deref())?;
965
966 if cmd.floating == Some(true) {
971 return open_pane_floating(state, &wstore, &event_bus, cmd.view, tab_id, meta).await;
972 }
973
974 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 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 {
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
1097async 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 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 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 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 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 {
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 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
1281fn 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 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
1332fn 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
1355fn 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 let gfs = global.as_ref()?;
1388 let block = wstore.get::<Block>(block_id).ok().flatten()?;
1389 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
1411fn 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
1427fn 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 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 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 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, );
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
1519fn 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 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 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; }
1576 let output_size = out_stat.size as u64;
1577
1578 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 if limit == 0 || total_lines == 0 || (offset as u64) >= total_lines {
1599 return Some(BlockfileReadRangeResult { lines: vec![], total: total_lines });
1600 }
1601
1602 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 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, 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 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, );
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
1735fn 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 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
1774fn 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 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
1819fn 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
1858fn 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
1892fn 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
1930fn 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 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 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 let current_line_count = block.meta.get("session:line_count")
1972 .and_then(|v| v.as_u64())
1973 .unwrap_or(0);
1974
1975 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 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 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 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 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 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 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 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 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
2126fn 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 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 Ok(Some(serde_json::to_value(&ActivitySummaryResult { summary }).unwrap()))
2218 })
2219 }),
2220 );
2221}
2222
2223async 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
2296fn 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 _ => {}
2369 }
2370 }
2371
2372 parts.join("\n")
2373}
2374
2375async fn invoke_cli_for_digest(
2378 cli_path: &str,
2379 prompt: &str,
2380 meta: &obj::MetaMapType,
2381) -> Result<String, String> {
2382 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 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 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
2460fn 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 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
2486fn 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
2502pub 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
2542fn 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 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 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 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 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
2650fn 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 model.to_string()
2678 }
2679}
2680
2681fn 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)) }
2713 Err(e) => Err(format!("agent.define: create stub instance: {e}")),
2714 }
2715}
2716
2717fn 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 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 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 let mut def = AgentDefinition {
2816 id: uuid::Uuid::new_v4().to_string(),
2817 slug: String::new(), 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 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(), };
2848
2849 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 match if_exists {
2858 "skip" => {
2859 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 if !cmd.provider.is_empty() || !cmd.model.is_empty() { updated.provider = provider.clone(); }
2903 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 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 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 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 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 assert!(global_output_source(&per_channel, &Some(global.clone()), &wstore, &block_id, "term").is_none());
3106 assert!(global_output_source(&per_channel, &None, &wstore, &block_id, "output").is_none());
3108 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 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 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 assert_eq!(global_zone_line_count(&global, zone), Some(3));
3155
3156 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 #[tokio::test]
3184 async fn docked_pane_open_block_is_in_reducer_and_tears_off() {
3185 let state = test_state();
3186
3187 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 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 {
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 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
3246fn 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)); }
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}