1use std::sync::Arc;
5use chrono::Utc;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use serde_json::json;
9
10use crate::backend::rpc::engine::WshRpcEngine;
11use crate::backend::rpc_types::{
12 COMMAND_LIST_AGENTS, COMMAND_CREATE_AGENT, COMMAND_UPDATE_AGENT,
13 COMMAND_DELETE_AGENT, COMMAND_GET_AGENT_CONTENT, COMMAND_SET_AGENT_CONTENT,
14 COMMAND_GET_ALL_AGENT_CONTENT,
15 COMMAND_LIST_AGENT_SKILLS, COMMAND_CREATE_AGENT_SKILL, COMMAND_UPDATE_AGENT_SKILL,
16 COMMAND_DELETE_AGENT_SKILL,
17 COMMAND_APPEND_AGENT_HISTORY, COMMAND_LIST_AGENT_HISTORY, COMMAND_SEARCH_AGENT_HISTORY,
18 COMMAND_IMPORT_AGENT_FROM_CLAW, COMMAND_IMPORT_AGENTS, COMMAND_EXPORT_AGENTS,
19 COMMAND_RESEED_AGENTS,
20 COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
22 COMMAND_CONTAINER_RUNTIME_AVAILABLE,
23 CommandAgentDefCreateFromTemplateData, AgentDefCreateFromTemplateResult,
24 CommandListAgentDefinitionsData,
25 COMMAND_AGENT_DEF_HIDE, COMMAND_AGENT_DEF_UNHIDE,
28 COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
29 CommandAgentDefHideData, AgentDefHideResult,
30 CommandCreateAgentDefinitionData, CommandUpdateAgentDefinitionData, CommandDeleteAgentDefinitionData,
31 CommandGetAgentContentData, CommandSetAgentContentData, CommandGetAllAgentContentData,
32 CommandListAgentSkillsData, CommandCreateAgentSkillData, CommandUpdateAgentSkillData,
33 CommandDeleteAgentSkillData,
34 CommandAppendAgentHistoryData, CommandListAgentHistoryData, CommandSearchAgentHistoryData,
35 CommandImportAgentFromClawData,
36 CommandImportAgentDefinitionsData, ImportAgentDefinitionsResult,
37 ExportAgentDefinitionsResult, AgentDefinitionExport, AgentSkillExport,
38 COMMAND_LIST_IDENTITY_ACCOUNTS, COMMAND_GET_IDENTITY_ACCOUNT,
40 COMMAND_UPSERT_IDENTITY_ACCOUNT, COMMAND_DELETE_IDENTITY_ACCOUNT,
41 COMMAND_ACCOUNT_KEY_VERIFY,
42 COMMAND_ACCOUNT_OAUTH_START, COMMAND_ACCOUNT_OAUTH_POLL, COMMAND_ACCOUNT_OAUTH_CANCEL,
43 COMMAND_LINK_AGENT_IDENTITY, COMMAND_UNLINK_AGENT_IDENTITY,
44 COMMAND_LIST_AGENT_IDENTITIES,
45 COMMAND_LIST_AGENT_INSTANCES, COMMAND_GET_AGENT_INSTANCE,
46 COMMAND_CREATE_AGENT_INSTANCE, COMMAND_UPDATE_AGENT_INSTANCE,
47 COMMAND_DELETE_AGENT_INSTANCE,
48 COMMAND_LIST_NAMED_AGENTS, COMMAND_HIDE_NAMED_AGENT,
49 CommandListNamedAgentsData, CommandHideNamedAgentData,
50 NamedAgentRow,
51 COMMAND_LIST_RECENT_SESSIONS, CommandListRecentSessionsData,
52 RecentSessionRow,
53 COMMAND_AGENT_SESSION_READ, COMMAND_AGENT_SESSION_WRITE_STATE,
55 COMMAND_AGENT_SESSION_APPEND_OUTPUT, COMMAND_AGENT_SESSION_ARCHIVE,
56 COMMAND_AGENT_SESSION_LIST_ARCHIVES,
57 CommandAgentSessionReadData, AgentSessionReadResult,
58 CommandAgentSessionWriteStateData, AgentSessionWriteStateResult,
59 CommandAgentSessionAppendOutputData, AgentSessionAppendOutputResult,
60 CommandAgentSessionArchiveData, AgentSessionArchiveResult,
61 CommandAgentSessionListArchivesData, AgentArchiveRow,
62 COMMAND_FORK_AGENT_DEFINITION,
63 COMMAND_FORK_AGENT_DEFINITION_SUGGEST,
64 CommandForkAgentDefinitionSuggestData, ForkAgentDefinitionSuggestResult,
65 CommandListIdentityAccountsData, CommandGetIdentityAccountData,
66 CommandDeleteIdentityAccountData,
67 CommandLinkAgentIdentityData, CommandUnlinkAgentIdentityData,
68 CommandListAgentIdentitiesData,
69 CommandListAgentInstancesData, CommandGetAgentInstanceData,
70 CommandCreateAgentInstanceData, CommandUpdateAgentInstanceData,
71 CommandDeleteAgentInstanceData,
72 CommandForkAgentDefinitionData,
73 COMMAND_LIST_IDENTITY_BUNDLES, COMMAND_GET_IDENTITY_BUNDLE,
75 COMMAND_UPSERT_IDENTITY_BUNDLE, COMMAND_DELETE_IDENTITY_BUNDLE,
76 COMMAND_BIND_IDENTITY_ACCOUNT, COMMAND_UNBIND_IDENTITY_ACCOUNT,
77 COMMAND_LIST_IDENTITY_BINDINGS,
78 COMMAND_LIST_MEMORIES, COMMAND_GET_MEMORY,
79 COMMAND_UPSERT_MEMORY, COMMAND_DELETE_MEMORY, COMMAND_REORDER_GLOBAL_BRAIN,
80 CommandGetIdentityBundleData, CommandDeleteIdentityBundleData,
81 CommandBindIdentityAccountData, CommandUnbindIdentityAccountData,
82 CommandListIdentityBindingsData,
83 CommandGetMemoryData, CommandDeleteMemoryData, CommandReorderGlobalBrainData,
84};
85use crate::backend::storage::{AgentDefinition, AgentContent, AgentSkill};
86use crate::backend::storage::store::{
87 AgentInstance, Identity, IdentityAccount, InstanceStatus, Memory, SecretRef,
88};
89use crate::backend::rpc_types::{
90 COMMAND_SUBPROCESS_SPAWN, COMMAND_AGENT_INPUT, COMMAND_AGENT_STOP,
91 CommandSubprocessSpawnData, CommandAgentInputData, CommandAgentStopData,
92};
93use crate::backend::obj::Block;
94use crate::backend::blockcontroller;
95
96use super::AppState;
97
98#[derive(serde::Deserialize)]
101#[serde(rename_all = "camelCase")]
102struct VerifyKeyReq {
103 provider: String,
105 name: String,
107 #[serde(default)]
108 display_name: String,
109 #[serde(default)]
111 kind: String,
112 api_key: String,
115 #[serde(default)]
119 validate: bool,
120 #[serde(default)]
122 account_id: String,
123 #[serde(default)]
127 context: serde_json::Value,
128}
129
130#[derive(serde::Deserialize)]
133#[serde(rename_all = "camelCase")]
134struct OAuthStartReq {
135 provider: String,
136 name: String,
137 #[serde(default)]
138 client_id: Option<String>,
139 #[serde(default)]
140 client_secret: Option<String>,
141}
142
143#[derive(serde::Deserialize)]
144#[serde(rename_all = "camelCase")]
145struct OAuthSessionReq {
146 session_id: String,
147}
148
149fn oauth_status_wire(s: &crate::identity::oauth_client::OAuthStatus) -> serde_json::Value {
151 use crate::identity::oauth_client::OAuthStatus;
152 match s {
153 OAuthStatus::Pending => serde_json::json!({ "status": "pending" }),
154 OAuthStatus::UrlAvailable { auth_url } => {
155 serde_json::json!({ "status": "url-available", "authUrl": auth_url })
156 }
157 OAuthStatus::CodeEmitted { user_code, verification_uri } => serde_json::json!({
158 "status": "code-emitted",
159 "userCode": user_code,
160 "verificationUri": verification_uri,
161 }),
162 OAuthStatus::Success { account_id } => {
163 serde_json::json!({ "status": "success", "accountId": account_id })
164 }
165 OAuthStatus::Failed { error } => serde_json::json!({ "status": "failed", "error": error }),
166 }
167}
168
169fn merge_json_object(base: &mut serde_json::Value, overlay: &serde_json::Value) {
171 if let (Some(b), Some(o)) = (base.as_object_mut(), overlay.as_object()) {
172 for (k, v) in o {
173 b.insert(k.clone(), v.clone());
174 }
175 }
176}
177
178pub fn register_agent_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
179 let wstore_lfa = state.wstore.clone();
194 engine.register_handler(
195 COMMAND_LIST_AGENTS,
196 Box::new(move |data, _ctx| {
197 let wstore = wstore_lfa.clone();
198 Box::pin(async move {
199 let cmd: CommandListAgentDefinitionsData =
205 serde_json::from_value(data).unwrap_or_default();
206 let agents = wstore.agent_def_list().map_err(|e| format!("listagents: {e}"))?;
207 let is_seeded_filter = cmd.is_seeded;
208 let include_hidden = cmd.include_hidden;
209 let filtered: Vec<_> = agents
210 .into_iter()
211 .filter(|a| match is_seeded_filter {
212 Some(flag) => a.is_seeded == flag,
213 None => true,
214 })
215 .filter(|a| {
221 include_hidden || a.is_seeded != 1 || a.user_hidden == 0
222 })
223 .collect();
224 Ok(Some(serde_json::to_value(&filtered).unwrap_or_default()))
225 })
226 }),
227 );
228
229 let wstore_cfa = state.wstore.clone();
231 let broker_cfa = state.broker.clone();
232 engine.register_handler(
233 COMMAND_CREATE_AGENT,
234 Box::new(move |data, _ctx| {
235 let wstore = wstore_cfa.clone();
236 let broker = broker_cfa.clone();
237 Box::pin(async move {
238 let cmd: CommandCreateAgentDefinitionData = serde_json::from_value(data)
239 .map_err(|e| format!("createagent: {e}"))?;
240 let now = SystemTime::now()
241 .duration_since(UNIX_EPOCH)
242 .unwrap_or_default()
243 .as_millis() as i64;
244 let mut agent = AgentDefinition {
249 id: uuid::Uuid::new_v4().to_string(),
250 slug: String::new(),
251 name: cmd.name,
252 icon: cmd.icon,
253 provider: cmd.provider,
254 description: cmd.description,
255 working_directory: cmd.working_directory,
256 shell: cmd.shell,
257 provider_flags: cmd.provider_flags,
258 auto_start: cmd.auto_start,
259 restart_on_crash: cmd.restart_on_crash,
260 idle_timeout_minutes: cmd.idle_timeout_minutes,
261 created_at: now,
262 agent_type: cmd.agent_type,
263 environment: cmd.environment,
264 agent_bus_id: cmd.agent_bus_id,
265 is_seeded: 0,
266 accounts: String::new(),
267 parent_id: String::new(),
268 branch_label: String::new(),
269 updated_at: now,
270 user_hidden: 0,
271 container_image: String::new(),
272 container_volumes: "[]".to_string(),
273 container_name: String::new(),
274 };
275 wstore.agent_def_insert(&mut agent).map_err(|e| format!("createagent: {e}"))?;
276 broker.publish(crate::backend::wps::WaveEvent {
277 event: "agents:changed".to_string(),
278 scopes: vec![],
279 sender: String::new(),
280 persist: 0,
281 data: None,
282 });
283 Ok(Some(serde_json::to_value(&agent).unwrap_or_default()))
284 })
285 }),
286 );
287
288 let wstore_ufa = state.wstore.clone();
290 let broker_ufa = state.broker.clone();
291 engine.register_handler(
292 COMMAND_UPDATE_AGENT,
293 Box::new(move |data, _ctx| {
294 let wstore = wstore_ufa.clone();
295 let broker = broker_ufa.clone();
296 Box::pin(async move {
297 let cmd: CommandUpdateAgentDefinitionData = serde_json::from_value(data)
298 .map_err(|e| format!("updateagent: {e}"))?;
299 let existing = wstore.agent_def_list().map_err(|e| format!("updateagent: {e}"))?;
301 let old = existing.iter().find(|a| a.id == cmd.id)
302 .ok_or_else(|| format!("updateagent: agent {} not found", cmd.id))?;
303 let mut agent = AgentDefinition {
307 id: cmd.id,
308 slug: old.slug.clone(),
309 name: cmd.name,
310 icon: cmd.icon,
311 provider: cmd.provider,
312 description: cmd.description,
313 working_directory: cmd.working_directory,
314 shell: cmd.shell,
315 provider_flags: cmd.provider_flags,
316 auto_start: cmd.auto_start,
317 restart_on_crash: cmd.restart_on_crash,
318 idle_timeout_minutes: cmd.idle_timeout_minutes,
319 created_at: old.created_at,
320 agent_type: cmd.agent_type,
321 environment: cmd.environment,
322 agent_bus_id: cmd.agent_bus_id,
323 is_seeded: old.is_seeded,
324 accounts: if cmd.accounts.is_empty() { old.accounts.clone() } else { cmd.accounts },
330 parent_id: old.parent_id.clone(),
334 branch_label: old.branch_label.clone(),
335 updated_at: old.updated_at,
339 user_hidden: old.user_hidden,
345 container_image: if cmd.container_image.is_empty() { old.container_image.clone() } else { cmd.container_image },
351 container_volumes: if cmd.container_volumes == "[]" { old.container_volumes.clone() } else { cmd.container_volumes },
352 container_name: old.container_name.clone(),
354 };
355 let found = wstore.agent_def_update(&mut agent).map_err(|e| format!("updateagent: {e}"))?;
356 if !found {
357 return Err(format!("updateagent: agent {} not found", agent.id));
358 }
359 broker.publish(crate::backend::wps::WaveEvent {
360 event: "agents:changed".to_string(),
361 scopes: vec![],
362 sender: String::new(),
363 persist: 0,
364 data: None,
365 });
366 Ok(Some(serde_json::to_value(&agent).unwrap_or_default()))
367 })
368 }),
369 );
370
371 let wstore_dfa = state.wstore.clone();
373 let broker_dfa = state.broker.clone();
374 engine.register_handler(
375 COMMAND_DELETE_AGENT,
376 Box::new(move |data, _ctx| {
377 let wstore = wstore_dfa.clone();
378 let broker = broker_dfa.clone();
379 Box::pin(async move {
380 let cmd: CommandDeleteAgentDefinitionData = serde_json::from_value(data)
381 .map_err(|e| format!("deleteagent: {e}"))?;
382 wstore.agent_def_delete(&cmd.id).map_err(|e| format!("deleteagent: {e}"))?;
383 broker.publish(crate::backend::wps::WaveEvent {
384 event: "agents:changed".to_string(),
385 scopes: vec![],
386 sender: String::new(),
387 persist: 0,
388 data: None,
389 });
390 Ok(None)
391 })
392 }),
393 );
394
395 let wstore_act = state.wstore.clone();
409 let broker_act = state.broker.clone();
410 engine.register_handler(
411 COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
412 Box::new(move |data, _ctx| {
413 let wstore = wstore_act.clone();
414 let broker = broker_act.clone();
415 Box::pin(async move {
416 let cmd: CommandAgentDefCreateFromTemplateData = serde_json::from_value(data)
417 .map_err(|e| format!("agentdefcreatefromtemplate: {e}"))?;
418 let name = cmd.name.trim().to_string();
419 if name.is_empty() {
420 return Err("agentdefcreatefromtemplate: name must be non-empty".into());
421 }
422 if name.chars().count() > 200 {
423 return Err(
424 "agentdefcreatefromtemplate: name must be ≤200 characters".into(),
425 );
426 }
427
428 let all = wstore
429 .agent_def_list()
430 .map_err(|e| format!("agentdefcreatefromtemplate: list: {e}"))?;
431 let template = all
432 .iter()
433 .find(|a| a.id == cmd.template_id)
434 .ok_or_else(|| {
435 format!(
436 "agentdefcreatefromtemplate: template {} not found",
437 cmd.template_id
438 )
439 })?;
440 if template.is_seeded != 1 {
441 return Err(format!(
442 "agentdefcreatefromtemplate: {} is not a seeded template (is_seeded={})",
443 cmd.template_id, template.is_seeded
444 ));
445 }
446 if all
447 .iter()
448 .any(|a| a.is_seeded == 0 && a.name.eq_ignore_ascii_case(&name))
449 {
450 return Err(format!(
451 "agentdefcreatefromtemplate: an agent named {:?} already exists",
452 name
453 ));
454 }
455
456 let now = SystemTime::now()
457 .duration_since(UNIX_EPOCH)
458 .map(|d| d.as_millis() as i64)
459 .unwrap_or(0);
460 let chosen_agent_type = match cmd.agent_type.trim() {
465 "host" | "container" => cmd.agent_type.trim().to_string(),
466 _ => template.agent_type.clone(),
467 };
468 let chosen_environment = if chosen_agent_type == "container" {
469 "docker".to_string()
470 } else {
471 "local".to_string()
472 };
473 let mut new_def = AgentDefinition {
474 id: uuid::Uuid::new_v4().to_string(),
475 slug: String::new(),
478 name: name.clone(),
479 icon: template.icon.clone(),
480 provider: template.provider.clone(),
481 description: template.description.clone(),
482 working_directory: String::new(),
486 shell: template.shell.clone(),
487 provider_flags: template.provider_flags.clone(),
488 auto_start: 0,
491 restart_on_crash: template.restart_on_crash,
492 idle_timeout_minutes: template.idle_timeout_minutes,
493 created_at: now,
494 agent_type: chosen_agent_type,
495 environment: chosen_environment,
496 agent_bus_id: String::new(),
497 is_seeded: 0,
498 accounts: String::new(),
499 parent_id: template.id.clone(),
500 branch_label: String::new(),
501 updated_at: now,
502 user_hidden: 0,
506 container_image: template.container_image.clone(),
509 container_volumes: template.container_volumes.clone(),
510 container_name: String::new(),
511 };
512 wstore
513 .agent_def_insert(&mut new_def)
514 .map_err(|e| format!("agentdefcreatefromtemplate: insert: {e}"))?;
515
516 broker.publish(crate::backend::wps::WaveEvent {
517 event: "agents:changed".to_string(),
518 scopes: vec![],
519 sender: String::new(),
520 persist: 0,
521 data: None,
522 });
523
524 let resp = AgentDefCreateFromTemplateResult {
525 definition_id: new_def.id.clone(),
526 identity_id: cmd.identity_id,
527 memory_id: cmd.memory_id,
528 };
529 tracing::info!(
530 template_id = %cmd.template_id,
531 new_definition_id = %new_def.id,
532 new_name = %new_def.name,
533 "agentdefcreatefromtemplate: cloned template into user agent"
534 );
535 Ok(Some(serde_json::to_value(&resp).unwrap_or_default()))
536 })
537 }),
538 );
539
540 let container_manager_cra = state.container_manager.clone();
550 engine.register_handler(
551 COMMAND_CONTAINER_RUNTIME_AVAILABLE,
552 Box::new(move |_data, _ctx| {
553 let cm = container_manager_cra.clone();
554 Box::pin(async move {
555 let available = match cm {
556 Some(mgr) => mgr.check_available().await.is_ok(),
557 None => false,
558 };
559 Ok(Some(serde_json::json!({ "available": available })))
560 })
561 }),
562 );
563
564 let wstore_hide = state.wstore.clone();
577 let broker_hide = state.broker.clone();
578 engine.register_handler(
579 COMMAND_AGENT_DEF_HIDE,
580 Box::new(move |data, _ctx| {
581 let wstore = wstore_hide.clone();
582 let broker = broker_hide.clone();
583 Box::pin(async move {
584 let cmd: CommandAgentDefHideData = serde_json::from_value(data)
585 .map_err(|e| format!("agentdefhide: {e}"))?;
586 let ok = wstore
587 .agent_def_set_hidden(&cmd.definition_id, true)
588 .map_err(|e| format!("agentdefhide: {e}"))?;
589 if ok {
590 broker.publish(crate::backend::wps::WaveEvent {
591 event: "agents:changed".to_string(),
592 scopes: vec![],
593 sender: String::new(),
594 persist: 0,
595 data: None,
596 });
597 tracing::info!(
598 definition_id = %cmd.definition_id,
599 "agentdefhide: hid template"
600 );
601 }
602 let resp = AgentDefHideResult { ok };
603 Ok(Some(serde_json::to_value(&resp).unwrap_or_default()))
604 })
605 }),
606 );
607
608 let wstore_unhide = state.wstore.clone();
612 let broker_unhide = state.broker.clone();
613 engine.register_handler(
614 COMMAND_AGENT_DEF_UNHIDE,
615 Box::new(move |data, _ctx| {
616 let wstore = wstore_unhide.clone();
617 let broker = broker_unhide.clone();
618 Box::pin(async move {
619 let cmd: CommandAgentDefHideData = serde_json::from_value(data)
620 .map_err(|e| format!("agentdefunhide: {e}"))?;
621 let ok = wstore
622 .agent_def_set_hidden(&cmd.definition_id, false)
623 .map_err(|e| format!("agentdefunhide: {e}"))?;
624 if ok {
625 broker.publish(crate::backend::wps::WaveEvent {
626 event: "agents:changed".to_string(),
627 scopes: vec![],
628 sender: String::new(),
629 persist: 0,
630 data: None,
631 });
632 tracing::info!(
633 definition_id = %cmd.definition_id,
634 "agentdefunhide: unhid template"
635 );
636 }
637 let resp = AgentDefHideResult { ok };
638 Ok(Some(serde_json::to_value(&resp).unwrap_or_default()))
639 })
640 }),
641 );
642
643 let wstore_lh = state.wstore.clone();
648 engine.register_handler(
649 COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
650 Box::new(move |_data, _ctx| {
651 let wstore = wstore_lh.clone();
652 Box::pin(async move {
653 let agents = wstore
654 .agent_def_list()
655 .map_err(|e| format!("agentdeflisthiddentemplates: {e}"))?;
656 let hidden: Vec<_> = agents
657 .into_iter()
658 .filter(|a| a.is_seeded == 1 && a.user_hidden == 1)
659 .collect();
660 Ok(Some(serde_json::to_value(&hidden).unwrap_or_default()))
661 })
662 }),
663 );
664
665 let wstore_gfc = state.wstore.clone();
667 engine.register_handler(
668 COMMAND_GET_AGENT_CONTENT,
669 Box::new(move |data, _ctx| {
670 let wstore = wstore_gfc.clone();
671 Box::pin(async move {
672 let cmd: CommandGetAgentContentData = serde_json::from_value(data)
673 .map_err(|e| format!("getagentcontent: {e}"))?;
674 let content = wstore.agent_content_get(&cmd.agent_id, &cmd.content_type)
675 .map_err(|e| format!("getagentcontent: {e}"))?;
676 Ok(content.map(|c| serde_json::to_value(&c).unwrap_or_default()))
677 })
678 }),
679 );
680
681 let wstore_sfc = state.wstore.clone();
683 let broker_sfc = state.broker.clone();
684 engine.register_handler(
685 COMMAND_SET_AGENT_CONTENT,
686 Box::new(move |data, _ctx| {
687 let wstore = wstore_sfc.clone();
688 let broker = broker_sfc.clone();
689 Box::pin(async move {
690 let cmd: CommandSetAgentContentData = serde_json::from_value(data)
691 .map_err(|e| format!("setagentcontent: {e}"))?;
692 let now = SystemTime::now()
693 .duration_since(UNIX_EPOCH)
694 .unwrap_or_default()
695 .as_millis() as i64;
696 let content = AgentContent {
697 agent_id: cmd.agent_id,
698 content_type: cmd.content_type,
699 content: cmd.content,
700 updated_at: now,
701 };
702 wstore.agent_content_set(&content).map_err(|e| format!("setagentcontent: {e}"))?;
703 broker.publish(crate::backend::wps::WaveEvent {
704 event: "agentcontent:changed".to_string(),
705 scopes: vec![],
706 sender: String::new(),
707 persist: 0,
708 data: None,
709 });
710 Ok(Some(serde_json::to_value(&content).unwrap_or_default()))
711 })
712 }),
713 );
714
715 let wstore_gafc = state.wstore.clone();
717 engine.register_handler(
718 COMMAND_GET_ALL_AGENT_CONTENT,
719 Box::new(move |data, _ctx| {
720 let wstore = wstore_gafc.clone();
721 Box::pin(async move {
722 let cmd: CommandGetAllAgentContentData = serde_json::from_value(data)
723 .map_err(|e| format!("getallagentcontent: {e}"))?;
724 let contents = wstore.agent_content_get_all(&cmd.agent_id)
725 .map_err(|e| format!("getallagentcontent: {e}"))?;
726 Ok(Some(serde_json::to_value(&contents).unwrap_or_default()))
727 })
728 }),
729 );
730
731 let wstore_lfs = state.wstore.clone();
735 engine.register_handler(
736 COMMAND_LIST_AGENT_SKILLS,
737 Box::new(move |data, _ctx| {
738 let wstore = wstore_lfs.clone();
739 Box::pin(async move {
740 let cmd: CommandListAgentSkillsData = serde_json::from_value(data)
741 .map_err(|e| format!("listagentskills: {e}"))?;
742 let skills = wstore.agent_skill_list(&cmd.agent_id)
743 .map_err(|e| format!("listagentskills: {e}"))?;
744 Ok(Some(serde_json::to_value(&skills).unwrap_or_default()))
745 })
746 }),
747 );
748
749 let wstore_cfs = state.wstore.clone();
751 let broker_cfs = state.broker.clone();
752 engine.register_handler(
753 COMMAND_CREATE_AGENT_SKILL,
754 Box::new(move |data, _ctx| {
755 let wstore = wstore_cfs.clone();
756 let broker = broker_cfs.clone();
757 Box::pin(async move {
758 let cmd: CommandCreateAgentSkillData = serde_json::from_value(data)
759 .map_err(|e| format!("createagentskill: {e}"))?;
760 let now = SystemTime::now()
761 .duration_since(UNIX_EPOCH)
762 .unwrap_or_default()
763 .as_millis() as i64;
764 let skill = AgentSkill {
765 id: uuid::Uuid::new_v4().to_string(),
766 agent_id: cmd.agent_id,
767 name: cmd.name,
768 trigger: cmd.trigger,
769 skill_type: cmd.skill_type,
770 description: cmd.description,
771 content: cmd.content,
772 created_at: now,
773 };
774 wstore.agent_skill_insert(&skill).map_err(|e| format!("createagentskill: {e}"))?;
775 broker.publish(crate::backend::wps::WaveEvent {
776 event: "agentskills:changed".to_string(),
777 scopes: vec![],
778 sender: String::new(),
779 persist: 0,
780 data: None,
781 });
782 Ok(Some(serde_json::to_value(&skill).unwrap_or_default()))
783 })
784 }),
785 );
786
787 let wstore_ufs = state.wstore.clone();
789 let broker_ufs = state.broker.clone();
790 engine.register_handler(
791 COMMAND_UPDATE_AGENT_SKILL,
792 Box::new(move |data, _ctx| {
793 let wstore = wstore_ufs.clone();
794 let broker = broker_ufs.clone();
795 Box::pin(async move {
796 let cmd: CommandUpdateAgentSkillData = serde_json::from_value(data)
797 .map_err(|e| format!("updateagentskill: {e}"))?;
798 let existing = wstore.agent_skill_get(&cmd.id)
799 .map_err(|e| format!("updateagentskill: {e}"))?
800 .ok_or_else(|| format!("updateagentskill: skill {} not found", cmd.id))?;
801 let skill = AgentSkill {
802 id: cmd.id,
803 agent_id: existing.agent_id,
804 name: cmd.name,
805 trigger: cmd.trigger,
806 skill_type: cmd.skill_type,
807 description: cmd.description,
808 content: cmd.content,
809 created_at: existing.created_at,
810 };
811 let found = wstore.agent_skill_update(&skill).map_err(|e| format!("updateagentskill: {e}"))?;
812 if !found {
813 return Err(format!("updateagentskill: skill {} not found", skill.id));
814 }
815 broker.publish(crate::backend::wps::WaveEvent {
816 event: "agentskills:changed".to_string(),
817 scopes: vec![],
818 sender: String::new(),
819 persist: 0,
820 data: None,
821 });
822 Ok(Some(serde_json::to_value(&skill).unwrap_or_default()))
823 })
824 }),
825 );
826
827 let wstore_dfs = state.wstore.clone();
829 let broker_dfs = state.broker.clone();
830 engine.register_handler(
831 COMMAND_DELETE_AGENT_SKILL,
832 Box::new(move |data, _ctx| {
833 let wstore = wstore_dfs.clone();
834 let broker = broker_dfs.clone();
835 Box::pin(async move {
836 let cmd: CommandDeleteAgentSkillData = serde_json::from_value(data)
837 .map_err(|e| format!("deleteagentskill: {e}"))?;
838 wstore.agent_skill_delete(&cmd.id).map_err(|e| format!("deleteagentskill: {e}"))?;
839 broker.publish(crate::backend::wps::WaveEvent {
840 event: "agentskills:changed".to_string(),
841 scopes: vec![],
842 sender: String::new(),
843 persist: 0,
844 data: None,
845 });
846 Ok(None)
847 })
848 }),
849 );
850
851 let wstore_afh = state.wstore.clone();
855 let broker_afh = state.broker.clone();
856 engine.register_handler(
857 COMMAND_APPEND_AGENT_HISTORY,
858 Box::new(move |data, _ctx| {
859 let wstore = wstore_afh.clone();
860 let broker = broker_afh.clone();
861 Box::pin(async move {
862 let cmd: CommandAppendAgentHistoryData = serde_json::from_value(data)
863 .map_err(|e| format!("appendagenthistory: {e}"))?;
864 let entry = wstore.agent_history_append(&cmd.agent_id, &cmd.entry)
865 .map_err(|e| format!("appendagenthistory: {e}"))?;
866 broker.publish(crate::backend::wps::WaveEvent {
867 event: "agenthistory:changed".to_string(),
868 scopes: vec![],
869 sender: String::new(),
870 persist: 0,
871 data: None,
872 });
873 Ok(Some(serde_json::to_value(&entry).unwrap_or_default()))
874 })
875 }),
876 );
877
878 let wstore_lfh = state.wstore.clone();
880 engine.register_handler(
881 COMMAND_LIST_AGENT_HISTORY,
882 Box::new(move |data, _ctx| {
883 let wstore = wstore_lfh.clone();
884 Box::pin(async move {
885 let cmd: CommandListAgentHistoryData = serde_json::from_value(data)
886 .map_err(|e| format!("listagenthistory: {e}"))?;
887 let entries = wstore.agent_history_list(
888 &cmd.agent_id,
889 cmd.session_date.as_deref(),
890 cmd.limit,
891 cmd.offset,
892 ).map_err(|e| format!("listagenthistory: {e}"))?;
893 Ok(Some(serde_json::to_value(&entries).unwrap_or_default()))
894 })
895 }),
896 );
897
898 let wstore_sfh = state.wstore.clone();
900 engine.register_handler(
901 COMMAND_SEARCH_AGENT_HISTORY,
902 Box::new(move |data, _ctx| {
903 let wstore = wstore_sfh.clone();
904 Box::pin(async move {
905 let cmd: CommandSearchAgentHistoryData = serde_json::from_value(data)
906 .map_err(|e| format!("searchagenthistory: {e}"))?;
907 let entries = wstore.agent_history_search(&cmd.agent_id, &cmd.query, cmd.limit)
908 .map_err(|e| format!("searchagenthistory: {e}"))?;
909 Ok(Some(serde_json::to_value(&entries).unwrap_or_default()))
910 })
911 }),
912 );
913
914 let wstore_ifc = state.wstore.clone();
918 let broker_ifc = state.broker.clone();
919 engine.register_handler(
920 COMMAND_IMPORT_AGENT_FROM_CLAW,
921 Box::new(move |data, _ctx| {
922 let wstore = wstore_ifc.clone();
923 let broker = broker_ifc.clone();
924 Box::pin(async move {
925 let cmd: CommandImportAgentFromClawData = serde_json::from_value(data)
926 .map_err(|e| format!("importagentfromclaw: {e}"))?;
927
928 let workspace_path = std::path::Path::new(&cmd.workspace_path);
929 if !workspace_path.exists() {
930 return Err(format!("importagentfromclaw: path does not exist: {}", cmd.workspace_path));
931 }
932
933 let now = SystemTime::now()
934 .duration_since(UNIX_EPOCH)
935 .unwrap_or_default()
936 .as_millis() as i64;
937
938 let mut provider = "claude".to_string();
940 let settings_path = workspace_path.join(".claude").join("settings.json");
941 if settings_path.exists() {
942 if let Ok(settings_str) = std::fs::read_to_string(&settings_path) {
943 if let Ok(settings) = serde_json::from_str::<serde_json::Value>(&settings_str) {
944 if let Some(p) = settings.get("provider").and_then(|v| v.as_str()) {
945 provider = p.to_string();
946 }
947 }
948 }
949 }
950
951 let mut agent = AgentDefinition {
955 id: uuid::Uuid::new_v4().to_string(),
956 slug: String::new(),
957 name: cmd.agent_name.clone(),
958 icon: "\u{2726}".to_string(),
959 provider,
960 description: format!("Imported from {}", cmd.workspace_path),
961 working_directory: cmd.workspace_path.clone(),
962 shell: String::new(),
963 provider_flags: String::new(),
964 auto_start: 0,
965 restart_on_crash: 0,
966 idle_timeout_minutes: 0,
967 created_at: now,
968 agent_type: "standalone".to_string(),
969 environment: String::new(),
970 agent_bus_id: String::new(),
971 is_seeded: 0,
972 accounts: String::new(),
973 parent_id: String::new(),
974 branch_label: String::new(),
975 updated_at: now,
976 user_hidden: 0,
977 container_image: String::new(),
978 container_volumes: "[]".to_string(),
979 container_name: String::new(),
980 };
981 wstore.agent_def_insert(&mut agent).map_err(|e| format!("importagentfromclaw: {e}"))?;
982
983 let claude_md_path = workspace_path.join("CLAUDE.md");
985 if claude_md_path.exists() {
986 if let Ok(content) = std::fs::read_to_string(&claude_md_path) {
987 let fc = AgentContent {
988 agent_id: agent.id.clone(),
989 content_type: "agentmd".to_string(),
990 content,
991 updated_at: now,
992 };
993 let _ = wstore.agent_content_set(&fc);
994 }
995 }
996
997 let mcp_path = workspace_path.join(".mcp.json");
999 if mcp_path.exists() {
1000 if let Ok(content) = std::fs::read_to_string(&mcp_path) {
1001 let fc = AgentContent {
1002 agent_id: agent.id.clone(),
1003 content_type: "mcp".to_string(),
1004 content,
1005 updated_at: now,
1006 };
1007 let _ = wstore.agent_content_set(&fc);
1008 }
1009 }
1010
1011 broker.publish(crate::backend::wps::WaveEvent {
1012 event: "agents:changed".to_string(),
1013 scopes: vec![],
1014 sender: String::new(),
1015 persist: 0,
1016 data: None,
1017 });
1018 Ok(Some(serde_json::to_value(&agent).unwrap_or_default()))
1019 })
1020 }),
1021 );
1022
1023 let wstore_rsfa = state.wstore.clone();
1025 let broker_rsfa = state.broker.clone();
1026 engine.register_handler(
1027 COMMAND_RESEED_AGENTS,
1028 Box::new(move |_data, _ctx| {
1029 let wstore = wstore_rsfa.clone();
1030 let broker = broker_rsfa.clone();
1031 Box::pin(async move {
1032 let deleted = wstore.agent_def_delete_seeded()
1034 .map_err(|e| format!("reseedagents: delete seeded: {e}"))?;
1035
1036 let report = crate::backend::agent_seed::seed_agents(&wstore)
1038 .map_err(|e| format!("reseedagents: seed: {e}"))?;
1039
1040 broker.publish(crate::backend::wps::WaveEvent {
1041 event: "agents:changed".to_string(),
1042 scopes: vec![],
1043 sender: String::new(),
1044 persist: 0,
1045 data: None,
1046 });
1047 Ok(Some(json!({
1048 "deleted": deleted,
1049 "created": report.created,
1050 "skipped": report.skipped,
1051 })))
1052 })
1053 }),
1054 );
1055
1056 let wstore_ifa = state.wstore.clone();
1058 let broker_ifa = state.broker.clone();
1059 engine.register_handler(
1060 COMMAND_IMPORT_AGENTS,
1061 Box::new(move |data, _ctx| {
1062 let wstore = wstore_ifa.clone();
1063 let broker = broker_ifa.clone();
1064 Box::pin(async move {
1065 let cmd: CommandImportAgentDefinitionsData = serde_json::from_value(data)
1066 .map_err(|e| format!("importagents: {e}"))?;
1067
1068 let now = SystemTime::now()
1069 .duration_since(UNIX_EPOCH)
1070 .unwrap_or_default()
1071 .as_millis() as i64;
1072
1073 let mut imported: Vec<String> = Vec::new();
1074 let mut skipped: Vec<String> = Vec::new();
1075 let mut failed: Vec<String> = Vec::new();
1076
1077 for agent_import in cmd.agents {
1078 let existing = wstore.agent_def_list()
1080 .unwrap_or_default()
1081 .into_iter()
1082 .any(|a| a.slug == agent_import.id);
1083
1084 if existing {
1085 skipped.push(agent_import.name.clone());
1086 continue;
1087 }
1088
1089 let mut agent = AgentDefinition {
1090 id: uuid::Uuid::new_v4().to_string(),
1091 slug: agent_import.id.clone(),
1092 name: agent_import.name.clone(),
1093 icon: agent_import.icon.clone(),
1094 provider: agent_import.provider.clone(),
1095 description: agent_import.description.clone(),
1096 working_directory: agent_import.working_directory.clone(),
1097 shell: agent_import.shell.clone(),
1098 provider_flags: String::new(),
1099 auto_start: 0,
1100 restart_on_crash: if agent_import.restart_on_crash { 1 } else { 0 },
1101 idle_timeout_minutes: 0,
1102 created_at: now,
1103 agent_type: agent_import.agent_type.clone(),
1104 environment: agent_import.environment.clone(),
1105 agent_bus_id: agent_import.agent_bus_id.clone(),
1106 is_seeded: 0,
1107 accounts: String::new(),
1108 parent_id: String::new(),
1109 branch_label: String::new(),
1110 updated_at: now,
1111 user_hidden: 0,
1112 container_image: String::new(),
1113 container_volumes: "[]".to_string(),
1114 container_name: String::new(),
1115 };
1116
1117 if let Err(e) = wstore.agent_def_insert(&mut agent) {
1118 failed.push(format!("{}: {e}", agent_import.name));
1119 continue;
1120 }
1121
1122 let mut content_ok = true;
1124 for (content_type, content) in &agent_import.content {
1125 let fc = AgentContent {
1126 agent_id: agent.id.clone(),
1127 content_type: content_type.clone(),
1128 content: content.clone(),
1129 updated_at: now,
1130 };
1131 if let Err(e) = wstore.agent_content_set(&fc) {
1132 tracing::warn!("import: failed to set content for agent {}: {e}", agent.id);
1133 content_ok = false;
1134 }
1135 }
1136
1137 let mut skills_ok = true;
1139 for skill_import in &agent_import.skills {
1140 let skill = AgentSkill {
1141 id: uuid::Uuid::new_v4().to_string(),
1142 agent_id: agent.id.clone(),
1143 name: skill_import.name.clone(),
1144 trigger: skill_import.trigger.clone(),
1145 skill_type: skill_import.skill_type.clone(),
1146 description: skill_import.description.clone(),
1147 content: skill_import.content.clone(),
1148 created_at: now,
1149 };
1150 if let Err(e) = wstore.agent_skill_insert(&skill) {
1151 tracing::warn!("import: failed to insert skill '{}' for agent {}: {e}", skill.name, agent.id);
1152 skills_ok = false;
1153 }
1154 }
1155
1156 if content_ok && skills_ok {
1157 imported.push(agent_import.name.clone());
1158 } else {
1159 failed.push(agent_import.name.clone());
1160 }
1161 }
1162
1163 broker.publish(crate::backend::wps::WaveEvent {
1164 event: "agents:changed".to_string(),
1165 scopes: vec![],
1166 sender: String::new(),
1167 persist: 0,
1168 data: None,
1169 });
1170
1171 let result = ImportAgentDefinitionsResult { imported, skipped, failed };
1172 Ok(Some(serde_json::to_value(&result).unwrap_or_default()))
1173 })
1174 }),
1175 );
1176
1177 let wstore_efa = state.wstore.clone();
1179 engine.register_handler(
1180 COMMAND_EXPORT_AGENTS,
1181 Box::new(move |_data, _ctx| {
1182 let wstore = wstore_efa.clone();
1183 Box::pin(async move {
1184 let agents = wstore.agent_def_list()
1185 .map_err(|e| format!("exportagents: list: {e}"))?;
1186
1187 let mut agent_exports: Vec<AgentDefinitionExport> = Vec::new();
1188
1189 for agent in agents {
1190 let content_map = wstore.agent_content_get_all(&agent.id)
1191 .unwrap_or_default()
1192 .into_iter()
1193 .map(|fc| (fc.content_type, fc.content))
1194 .collect::<std::collections::HashMap<String, String>>();
1195
1196 let skills = wstore.agent_skill_list(&agent.id)
1197 .unwrap_or_default()
1198 .into_iter()
1199 .map(|s| AgentSkillExport {
1200 name: s.name,
1201 trigger: s.trigger,
1202 skill_type: s.skill_type,
1203 description: s.description,
1204 content: s.content,
1205 })
1206 .collect::<Vec<_>>();
1207
1208 agent_exports.push(AgentDefinitionExport {
1209 id: agent.slug.clone(),
1210 name: agent.name,
1211 icon: agent.icon,
1212 description: agent.description,
1213 provider: agent.provider,
1214 shell: agent.shell,
1215 working_directory: agent.working_directory,
1216 agent_bus_id: agent.agent_bus_id,
1217 agent_type: agent.agent_type,
1218 environment: agent.environment,
1219 restart_on_crash: agent.restart_on_crash != 0,
1220 content: content_map,
1221 skills,
1222 });
1223 }
1224
1225 let exported_at = Utc::now().to_rfc3339();
1226
1227 let result = ExportAgentDefinitionsResult {
1228 version: 4,
1229 exported_at,
1230 source: "agentmux-export".to_string(),
1231 agents: agent_exports,
1232 };
1233 Ok(Some(serde_json::to_value(&result).unwrap_or_default()))
1234 })
1235 }),
1236 );
1237
1238 register_v6_handlers(engine, state);
1239}
1240
1241fn register_v6_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
1244 let wstore = state.wstore.clone();
1247 engine.register_handler(
1248 COMMAND_LIST_IDENTITY_ACCOUNTS,
1249 Box::new(move |data, _ctx| {
1250 let wstore = wstore.clone();
1251 Box::pin(async move {
1252 let cmd: CommandListIdentityAccountsData =
1253 serde_json::from_value(data).unwrap_or_default();
1254 let accounts = wstore
1255 .identity_list(cmd.provider.as_deref())
1256 .map_err(|e| format!("listidentityaccounts: {e}"))?;
1257 Ok(Some(serde_json::to_value(&accounts).unwrap_or_default()))
1258 })
1259 }),
1260 );
1261
1262 let wstore = state.wstore.clone();
1263 engine.register_handler(
1264 COMMAND_GET_IDENTITY_ACCOUNT,
1265 Box::new(move |data, _ctx| {
1266 let wstore = wstore.clone();
1267 Box::pin(async move {
1268 let cmd: CommandGetIdentityAccountData =
1269 serde_json::from_value(data).map_err(|e| format!("getidentityaccount: {e}"))?;
1270 match wstore
1271 .identity_get(&cmd.id)
1272 .map_err(|e| format!("getidentityaccount: {e}"))?
1273 {
1274 Some(a) => Ok(Some(serde_json::to_value(&a).unwrap_or_default())),
1275 None => Err(format!("getidentityaccount: not found id={}", cmd.id)),
1276 }
1277 })
1278 }),
1279 );
1280
1281 let wstore = state.wstore.clone();
1282 let broker = state.broker.clone();
1283 engine.register_handler(
1284 COMMAND_UPSERT_IDENTITY_ACCOUNT,
1285 Box::new(move |data, _ctx| {
1286 let wstore = wstore.clone();
1287 let broker = broker.clone();
1288 Box::pin(async move {
1289 let mut account: IdentityAccount = serde_json::from_value(data)
1293 .map_err(|e| format!("upsertidentityaccount: {e}"))?;
1294 if account.id.is_empty() {
1295 account.id = uuid::Uuid::new_v4().to_string();
1296 }
1297 let now = SystemTime::now()
1298 .duration_since(UNIX_EPOCH)
1299 .map(|d| d.as_millis() as i64)
1300 .unwrap_or(0);
1301 if account.created_at == 0 {
1302 account.created_at = now;
1303 }
1304 account.updated_at = now;
1305 wstore
1306 .identity_upsert(&account)
1307 .map_err(|e| format!("upsertidentityaccount: {e}"))?;
1308 broker.publish(crate::backend::wps::WaveEvent {
1309 event: "identityaccounts:changed".to_string(),
1310 scopes: vec![],
1311 sender: String::new(),
1312 persist: 0,
1313 data: None,
1314 });
1315 Ok(Some(serde_json::to_value(&account).unwrap_or_default()))
1316 })
1317 }),
1318 );
1319
1320 let wstore = state.wstore.clone();
1325 let broker = state.broker.clone();
1326 engine.register_handler(
1327 COMMAND_ACCOUNT_KEY_VERIFY,
1328 Box::new(move |data, _ctx| {
1329 let wstore = wstore.clone();
1330 let broker = broker.clone();
1331 Box::pin(async move {
1332 let req: VerifyKeyReq = serde_json::from_value(data)
1334 .map_err(|e| format!("account.key.verify: {e}"))?;
1335
1336 let is_new = req.account_id.is_empty();
1339 let account_id = if is_new {
1340 uuid::Uuid::new_v4().to_string()
1341 } else {
1342 req.account_id.clone()
1343 };
1344
1345 let (status, metadata, masked_tail, valid) = if req.validate {
1348 let outcome =
1349 crate::identity::key_validator::validate(&req.provider, &req.api_key).await;
1350 if !outcome.valid {
1351 return Ok(Some(serde_json::json!({
1354 "valid": false,
1355 "error": outcome.error.unwrap_or_else(|| "validation failed".to_string()),
1356 })));
1357 }
1358 ("valid".to_string(), outcome.metadata, outcome.masked_tail, true)
1359 } else {
1360 (
1361 "unknown".to_string(),
1362 serde_json::json!({}),
1363 crate::identity::key_validator::masked_tail(&req.api_key),
1364 false,
1365 )
1366 };
1367
1368 {
1372 let aid = account_id.clone();
1373 let key = req.api_key.clone();
1374 tokio::task::spawn_blocking(move || {
1375 crate::identity::secret_store::put(&aid, &key)
1376 })
1377 .await
1378 .map_err(|e| format!("account.key.verify: keychain task: {e}"))?
1379 .map_err(|e| format!("account.key.verify: {e}"))?;
1380 }
1381
1382 let existing = wstore.identity_get(&account_id).ok().flatten();
1385
1386 let mut context = existing
1390 .as_ref()
1391 .map(|a| a.context.clone())
1392 .unwrap_or_else(|| serde_json::json!({}));
1393 if !context.is_object() {
1394 context = serde_json::json!({});
1395 }
1396 merge_json_object(&mut context, &req.context);
1397 merge_json_object(&mut context, &metadata);
1398 if let serde_json::Value::Object(ref mut m) = context {
1399 m.insert("masked_tail".to_string(), serde_json::json!(masked_tail));
1400 }
1401
1402 let now = SystemTime::now()
1403 .duration_since(UNIX_EPOCH)
1404 .map(|d| d.as_millis() as i64)
1405 .unwrap_or(0);
1406 let created_at = existing
1407 .as_ref()
1408 .map(|a| a.created_at)
1409 .filter(|&c| c != 0)
1410 .unwrap_or(now);
1411
1412 let account = IdentityAccount {
1413 id: account_id.clone(),
1414 name: req.name.clone(),
1415 provider: req.provider.clone(),
1416 kind: if req.kind.is_empty() { "api_key".to_string() } else { req.kind.clone() },
1417 display_name: req.display_name.clone(),
1418 secret_ref: SecretRef::Keychain {
1419 service: crate::identity::secret_store::SERVICE.to_string(),
1420 account: crate::identity::secret_store::account_key(&account_id),
1421 },
1422 context,
1423 status,
1424 created_at,
1425 updated_at: now,
1426 };
1427 if let Err(e) = wstore.identity_upsert(&account) {
1428 if is_new {
1438 let aid = account_id.clone();
1439 if let Err(de) = tokio::task::spawn_blocking(move || {
1440 crate::identity::secret_store::delete(&aid)
1441 })
1442 .await
1443 .unwrap_or_else(|je| Err(format!("join: {je}")))
1444 {
1445 tracing::warn!(
1446 target: "identity",
1447 "rollback of orphaned keychain secret for {} failed: {de}",
1448 account_id,
1449 );
1450 }
1451 }
1452 return Err(format!("account.key.verify: {e}"));
1453 }
1454 broker.publish(crate::backend::wps::WaveEvent {
1455 event: "identityaccounts:changed".to_string(),
1456 scopes: vec![],
1457 sender: String::new(),
1458 persist: 0,
1459 data: None,
1460 });
1461 Ok(Some(serde_json::json!({
1462 "valid": valid,
1463 "accountId": account_id,
1464 "maskedTail": account.context.get("masked_tail").cloned().unwrap_or_default(),
1465 "status": account.status,
1466 "metadata": account.context,
1467 })))
1468 })
1469 }),
1470 );
1471
1472 let oauth_wstore = state.wstore.clone();
1476 engine.register_handler(
1477 COMMAND_ACCOUNT_OAUTH_START,
1478 Box::new(move |data, _ctx| {
1479 let wstore = oauth_wstore.clone();
1480 Box::pin(async move {
1481 let req: OAuthStartReq = serde_json::from_value(data)
1482 .map_err(|e| format!("account.oauth.start: {e}"))?;
1483 let byo = req.client_id.map(|cid| {
1484 crate::identity::oauth_client::ByoCredentials {
1485 client_id: cid,
1486 client_secret: req.client_secret,
1487 }
1488 });
1489 match crate::identity::oauth_client::start(&req.provider, req.name, byo, wstore) {
1490 Ok((session_id, status)) => Ok(Some(serde_json::json!({
1491 "sessionId": session_id,
1492 "status": oauth_status_wire(&status),
1493 }))),
1494 Err(e) => Ok(Some(serde_json::json!({ "error": e }))),
1497 }
1498 })
1499 }),
1500 );
1501
1502 engine.register_handler(
1503 COMMAND_ACCOUNT_OAUTH_POLL,
1504 Box::new(move |data, _ctx| {
1505 Box::pin(async move {
1506 let req: OAuthSessionReq = serde_json::from_value(data)
1507 .map_err(|e| format!("account.oauth.poll: {e}"))?;
1508 match crate::identity::oauth_client::manager().poll(&req.session_id) {
1509 Some(s) => Ok(Some(oauth_status_wire(&s))),
1510 None => Err(format!("account.oauth.poll: unknown session {}", req.session_id)),
1511 }
1512 })
1513 }),
1514 );
1515
1516 engine.register_handler(
1517 COMMAND_ACCOUNT_OAUTH_CANCEL,
1518 Box::new(move |data, _ctx| {
1519 Box::pin(async move {
1520 let req: OAuthSessionReq = serde_json::from_value(data)
1521 .map_err(|e| format!("account.oauth.cancel: {e}"))?;
1522 let cancelled = crate::identity::oauth_client::manager().cancel(&req.session_id);
1523 Ok(Some(serde_json::json!({ "cancelled": cancelled })))
1524 })
1525 }),
1526 );
1527
1528 let wstore = state.wstore.clone();
1529 let broker = state.broker.clone();
1530 engine.register_handler(
1531 COMMAND_DELETE_IDENTITY_ACCOUNT,
1532 Box::new(move |data, _ctx| {
1533 let wstore = wstore.clone();
1534 let broker = broker.clone();
1535 Box::pin(async move {
1536 let cmd: CommandDeleteIdentityAccountData = serde_json::from_value(data)
1537 .map_err(|e| format!("deleteidentityaccount: {e}"))?;
1538 if let Ok(Some(acct)) = wstore.identity_get(&cmd.id) {
1542 if matches!(acct.secret_ref, SecretRef::Keychain { .. }) {
1543 let aid = cmd.id.clone();
1544 let res = tokio::task::spawn_blocking(move || {
1545 crate::identity::secret_store::delete(&aid)
1546 })
1547 .await
1548 .unwrap_or_else(|je| Err(format!("join: {je}")));
1549 if let Err(e) = res {
1550 tracing::warn!(target: "identity", "keychain delete for {} failed: {e}", cmd.id);
1551 }
1552 }
1553 }
1554 let deleted = wstore
1555 .identity_delete(&cmd.id)
1556 .map_err(|e| format!("deleteidentityaccount: {e}"))?;
1557 if deleted {
1558 broker.publish(crate::backend::wps::WaveEvent {
1559 event: "identityaccounts:changed".to_string(),
1560 scopes: vec![],
1561 sender: String::new(),
1562 persist: 0,
1563 data: None,
1564 });
1565 }
1566 Ok(Some(json!({ "deleted": deleted })))
1567 })
1568 }),
1569 );
1570
1571 let wstore = state.wstore.clone();
1574 let broker = state.broker.clone();
1575 engine.register_handler(
1576 COMMAND_LINK_AGENT_IDENTITY,
1577 Box::new(move |data, _ctx| {
1578 let wstore = wstore.clone();
1579 let broker = broker.clone();
1580 Box::pin(async move {
1581 let cmd: CommandLinkAgentIdentityData = serde_json::from_value(data)
1582 .map_err(|e| format!("linkagentidentity: {e}"))?;
1583 wstore
1584 .agent_identity_link(&cmd.agent_id, &cmd.account_id, &cmd.provider)
1585 .map_err(|e| format!("linkagentidentity: {e}"))?;
1586 broker.publish(crate::backend::wps::WaveEvent {
1587 event: format!("agentidentities:changed:{}", cmd.agent_id),
1588 scopes: vec![],
1589 sender: String::new(),
1590 persist: 0,
1591 data: None,
1592 });
1593 Ok(None)
1594 })
1595 }),
1596 );
1597
1598 let wstore = state.wstore.clone();
1599 let broker = state.broker.clone();
1600 engine.register_handler(
1601 COMMAND_UNLINK_AGENT_IDENTITY,
1602 Box::new(move |data, _ctx| {
1603 let wstore = wstore.clone();
1604 let broker = broker.clone();
1605 Box::pin(async move {
1606 let cmd: CommandUnlinkAgentIdentityData = serde_json::from_value(data)
1607 .map_err(|e| format!("unlinkagentidentity: {e}"))?;
1608 let removed = wstore
1609 .agent_identity_unlink(&cmd.agent_id, &cmd.provider)
1610 .map_err(|e| format!("unlinkagentidentity: {e}"))?;
1611 if removed {
1612 broker.publish(crate::backend::wps::WaveEvent {
1613 event: format!("agentidentities:changed:{}", cmd.agent_id),
1614 scopes: vec![],
1615 sender: String::new(),
1616 persist: 0,
1617 data: None,
1618 });
1619 }
1620 Ok(Some(json!({ "unlinked": removed })))
1621 })
1622 }),
1623 );
1624
1625 let wstore = state.wstore.clone();
1626 engine.register_handler(
1627 COMMAND_LIST_AGENT_IDENTITIES,
1628 Box::new(move |data, _ctx| {
1629 let wstore = wstore.clone();
1630 Box::pin(async move {
1631 let cmd: CommandListAgentIdentitiesData = serde_json::from_value(data)
1632 .map_err(|e| format!("listagentidentities: {e}"))?;
1633 let rows = wstore
1634 .agent_identity_list_for_agent(&cmd.agent_id)
1635 .map_err(|e| format!("listagentidentities: {e}"))?;
1636 Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
1637 })
1638 }),
1639 );
1640
1641 let wstore = state.wstore.clone();
1644 engine.register_handler(
1645 COMMAND_LIST_AGENT_INSTANCES,
1646 Box::new(move |data, _ctx| {
1647 let wstore = wstore.clone();
1648 Box::pin(async move {
1649 let cmd: CommandListAgentInstancesData =
1650 serde_json::from_value(data).unwrap_or_default();
1651 let rows = wstore
1652 .instance_list(cmd.definition_id.as_deref(), cmd.status.as_deref())
1653 .map_err(|e| format!("listagentinstances: {e}"))?;
1654 Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
1655 })
1656 }),
1657 );
1658
1659 let wstore = state.wstore.clone();
1660 engine.register_handler(
1661 COMMAND_GET_AGENT_INSTANCE,
1662 Box::new(move |data, _ctx| {
1663 let wstore = wstore.clone();
1664 Box::pin(async move {
1665 let cmd: CommandGetAgentInstanceData = serde_json::from_value(data)
1666 .map_err(|e| format!("getagentinstance: {e}"))?;
1667 match wstore
1668 .instance_get(&cmd.id)
1669 .map_err(|e| format!("getagentinstance: {e}"))?
1670 {
1671 Some(i) => Ok(Some(serde_json::to_value(&i).unwrap_or_default())),
1672 None => Err(format!("getagentinstance: not found id={}", cmd.id)),
1673 }
1674 })
1675 }),
1676 );
1677
1678 let wstore = state.wstore.clone();
1679 let broker = state.broker.clone();
1680 engine.register_handler(
1681 COMMAND_CREATE_AGENT_INSTANCE,
1682 Box::new(move |data, _ctx| {
1683 let wstore = wstore.clone();
1684 let broker = broker.clone();
1685 Box::pin(async move {
1686 let cmd: CommandCreateAgentInstanceData = serde_json::from_value(data)
1687 .map_err(|e| format!("createagentinstance: {e}"))?;
1688 let now = SystemTime::now()
1689 .duration_since(UNIX_EPOCH)
1690 .map(|d| d.as_millis() as i64)
1691 .unwrap_or(0);
1692 let inst = AgentInstance {
1693 id: uuid::Uuid::new_v4().to_string(),
1694 definition_id: cmd.definition_id,
1695 parent_instance_id: cmd.parent_instance_id,
1696 block_id: cmd.block_id,
1697 session_id: String::new(),
1698 status: InstanceStatus::Running.as_str().to_string(),
1699 github_context: String::new(),
1700 started_at: now,
1701 ended_at: 0,
1702 created_at: now,
1703 identity_id: cmd.identity_id,
1708 memory_id: cmd.memory_id,
1709 instance_name: cmd.instance_name.clone(),
1715 working_directory: cmd.working_directory.clone(),
1716 display_hidden: false,
1717 };
1718 wstore
1719 .instance_create(&inst)
1720 .map_err(|e| format!("createagentinstance: {e}"))?;
1721
1722 if !inst.block_id.is_empty()
1730 && crate::backend::agent_session::is_valid_definition_id(&inst.definition_id)
1731 {
1732 let zone = crate::backend::agent_session::agent_current_zone(
1733 &inst.definition_id,
1734 );
1735 let mut meta_update = crate::backend::obj::MetaMapType::new();
1736 meta_update.insert(
1737 "agent:sessionZone".to_string(),
1738 serde_json::json!(zone),
1739 );
1740 let oref_str = format!("block:{}", inst.block_id);
1741 if let Err(e) = crate::server::service::update_object_meta(
1742 &wstore, &oref_str, &meta_update,
1743 ) {
1744 tracing::warn!(
1749 block_id = %inst.block_id,
1750 definition_id = %inst.definition_id,
1751 error = %e,
1752 "createagentinstance: failed to stamp agent:sessionZone"
1753 );
1754 }
1755 }
1756
1757 broker.publish(crate::backend::wps::WaveEvent {
1758 event: format!("agentinstances:changed:{}", inst.definition_id),
1759 scopes: vec![],
1760 sender: String::new(),
1761 persist: 0,
1762 data: None,
1763 });
1764 Ok(Some(serde_json::to_value(&inst).unwrap_or_default()))
1765 })
1766 }),
1767 );
1768
1769 let wstore = state.wstore.clone();
1770 let broker = state.broker.clone();
1771 engine.register_handler(
1772 COMMAND_UPDATE_AGENT_INSTANCE,
1773 Box::new(move |data, _ctx| {
1774 let wstore = wstore.clone();
1775 let broker = broker.clone();
1776 Box::pin(async move {
1777 let cmd: CommandUpdateAgentInstanceData = serde_json::from_value(data)
1778 .map_err(|e| format!("updateagentinstance: {e}"))?;
1779 let upd = crate::backend::storage::InstanceUpdate {
1788 block_id: cmd.block_id,
1789 session_id: cmd.session_id,
1790 status: cmd.status,
1791 github_context: cmd.github_context,
1792 ended_at: cmd.ended_at,
1793 };
1794 let fresh = wstore
1795 .instance_update_partial(&cmd.id, &upd)
1796 .map_err(|e| format!("updateagentinstance: {e}"))?
1797 .ok_or_else(|| format!("updateagentinstance: not found id={}", cmd.id))?;
1798 broker.publish(crate::backend::wps::WaveEvent {
1799 event: format!("agentinstances:changed:{}", fresh.definition_id),
1800 scopes: vec![],
1801 sender: String::new(),
1802 persist: 0,
1803 data: None,
1804 });
1805 Ok(Some(serde_json::to_value(&fresh).unwrap_or_default()))
1806 })
1807 }),
1808 );
1809
1810 let wstore = state.wstore.clone();
1811 let broker = state.broker.clone();
1812 engine.register_handler(
1813 COMMAND_DELETE_AGENT_INSTANCE,
1814 Box::new(move |data, _ctx| {
1815 let wstore = wstore.clone();
1816 let broker = broker.clone();
1817 Box::pin(async move {
1818 let cmd: CommandDeleteAgentInstanceData = serde_json::from_value(data)
1819 .map_err(|e| format!("deleteagentinstance: {e}"))?;
1820 let definition_id = wstore
1822 .instance_get(&cmd.id)
1823 .map_err(|e| format!("deleteagentinstance: {e}"))?
1824 .map(|i| i.definition_id);
1825 let deleted = wstore
1826 .instance_delete(&cmd.id)
1827 .map_err(|e| format!("deleteagentinstance: {e}"))?;
1828 if let Some(def_id) = definition_id.filter(|_| deleted) {
1829 broker.publish(crate::backend::wps::WaveEvent {
1830 event: format!("agentinstances:changed:{}", def_id),
1831 scopes: vec![],
1832 sender: String::new(),
1833 persist: 0,
1834 data: None,
1835 });
1836 }
1837 Ok(Some(json!({ "deleted": deleted })))
1838 })
1839 }),
1840 );
1841
1842 let wstore = state.wstore.clone();
1848 engine.register_handler(
1849 COMMAND_LIST_NAMED_AGENTS,
1850 Box::new(move |data, _ctx| {
1851 let wstore = wstore.clone();
1852 Box::pin(async move {
1853 let cmd: CommandListNamedAgentsData =
1854 serde_json::from_value(data).unwrap_or_default();
1855 let limit = if cmd.limit == 0 {
1856 200
1857 } else {
1858 cmd.limit.min(1000)
1859 };
1860 let defs = wstore
1865 .agent_def_list()
1866 .map_err(|e| format!("listnamedagents: agent_def_list: {e}"))?;
1867 let identities = wstore
1868 .bundle_identity_list()
1869 .map_err(|e| format!("listnamedagents: bundle_identity_list: {e}"))?;
1870 let memories = wstore
1871 .bundle_memory_list()
1872 .map_err(|e| format!("listnamedagents: bundle_memory_list: {e}"))?;
1873
1874 let rows: Vec<NamedAgentRow> = match wstore.shared_agent_registry() {
1881 Some(reg) => {
1882 let agents_root = wstore.registry_agents_base();
1887 let mut records = reg
1888 .list_active()
1889 .map_err(|e| format!("listnamedagents: registry: {e}"))?;
1890 if let Some(def_filter) = cmd.definition_id.as_deref() {
1891 records.retain(|r| r.data.definition_id == def_filter);
1892 }
1893 records.sort_by(|a, b| {
1894 b.data
1895 .last_launched_at_ms
1896 .cmp(&a.data.last_launched_at_ms)
1897 });
1898 records.truncate(limit);
1899 let sqlite_rows: Vec<AgentInstance> = wstore
1914 .instance_list_named(
1915 records.len().max(1),
1916 cmd.definition_id.as_deref(),
1917 None,
1918 false,
1919 )
1920 .unwrap_or_default();
1921 let sqlite_by_id: std::collections::HashMap<&str, &AgentInstance> =
1922 sqlite_rows.iter().map(|i| (i.id.as_str(), i)).collect();
1923 records
1924 .into_iter()
1925 .map(|rec| {
1926 let d = rec.data;
1927 let def = defs.iter().find(|x| x.id == d.definition_id);
1928 let identity_id_str =
1929 d.identity_id.clone().unwrap_or_default();
1930 let memory_id_str = d.memory_id.clone().unwrap_or_default();
1931 let identity_name = if identity_id_str.is_empty() {
1932 "(ambient creds)".to_string()
1933 } else {
1934 identities
1935 .iter()
1936 .find(|i| i.id == identity_id_str)
1937 .map(|i| i.name.clone())
1938 .unwrap_or_else(|| "(missing identity)".to_string())
1939 };
1940 let memory_name = if memory_id_str.is_empty() {
1941 "(vanilla CLI)".to_string()
1942 } else {
1943 memories
1944 .iter()
1945 .find(|m| m.id == memory_id_str)
1946 .map(|m| m.name.clone())
1947 .unwrap_or_else(|| "(missing memory)".to_string())
1948 };
1949 let working_directory = if let Some(src) =
1957 d.source_agents_base.as_deref()
1958 {
1959 std::path::Path::new(src)
1960 .join(&d.working_dir)
1961 .to_string_lossy()
1962 .to_string()
1963 } else {
1964 match agents_root.as_ref() {
1965 Some(root) => root
1966 .join(&d.working_dir)
1967 .to_string_lossy()
1968 .to_string(),
1969 None => d.working_dir.clone(),
1970 }
1971 };
1972 let (block_id_hint, status, ended_at) =
1982 match sqlite_by_id.get(d.instance_id.as_str()) {
1983 Some(inst) => (
1984 inst.block_id.clone(),
1985 inst.status.clone(),
1986 inst.ended_at,
1987 ),
1988 None => (String::new(), "available".to_string(), 0),
1989 };
1990 NamedAgentRow {
1991 instance_id: d.instance_id,
1992 instance_name: d.instance_name,
1993 definition_id: d.definition_id.clone(),
1994 definition_name: def
1995 .map(|x| x.name.clone())
1996 .unwrap_or_else(|| "(missing definition)".to_string()),
1997 provider: def
1998 .map(|x| x.provider.clone())
1999 .unwrap_or_default(),
2000 working_directory,
2001 identity_id: identity_id_str,
2002 identity_name,
2003 memory_id: memory_id_str,
2004 memory_name,
2005 started_at: d.last_launched_at_ms,
2006 ended_at,
2007 status,
2008 block_id_hint,
2009 }
2010 })
2011 .collect()
2012 }
2013 None => {
2014 let instances = wstore
2019 .instance_list_named(
2020 limit,
2021 cmd.definition_id.as_deref(),
2022 None,
2023 false,
2024 )
2025 .map_err(|e| format!("listnamedagents: {e}"))?;
2026 instances
2027 .into_iter()
2028 .map(|inst| {
2029 let def = defs.iter().find(|d| d.id == inst.definition_id);
2030 let identity_name = if inst.identity_id.is_empty() {
2031 "(ambient creds)".to_string()
2032 } else {
2033 identities
2034 .iter()
2035 .find(|i| i.id == inst.identity_id)
2036 .map(|i| i.name.clone())
2037 .unwrap_or_else(|| "(missing identity)".to_string())
2038 };
2039 let memory_name = if inst.memory_id.is_empty() {
2040 "(vanilla CLI)".to_string()
2041 } else {
2042 memories
2043 .iter()
2044 .find(|m| m.id == inst.memory_id)
2045 .map(|m| m.name.clone())
2046 .unwrap_or_else(|| "(missing memory)".to_string())
2047 };
2048 NamedAgentRow {
2049 instance_id: inst.id,
2050 instance_name: inst.instance_name,
2051 definition_id: inst.definition_id.clone(),
2052 definition_name: def
2053 .map(|d| d.name.clone())
2054 .unwrap_or_else(|| "(missing definition)".to_string()),
2055 provider: def
2056 .map(|d| d.provider.clone())
2057 .unwrap_or_default(),
2058 working_directory: inst.working_directory,
2059 identity_id: inst.identity_id,
2060 identity_name,
2061 memory_id: inst.memory_id,
2062 memory_name,
2063 started_at: inst.started_at,
2064 ended_at: inst.ended_at,
2065 status: inst.status,
2066 block_id_hint: inst.block_id,
2067 }
2068 })
2069 .collect()
2070 }
2071 };
2072
2073 Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
2074 })
2075 }),
2076 );
2077
2078 let wstore = state.wstore.clone();
2081 let broker = state.broker.clone();
2082 engine.register_handler(
2083 COMMAND_HIDE_NAMED_AGENT,
2084 Box::new(move |data, _ctx| {
2085 let wstore = wstore.clone();
2086 let broker = broker.clone();
2087 Box::pin(async move {
2088 let cmd: CommandHideNamedAgentData = serde_json::from_value(data)
2089 .map_err(|e| format!("hidenamedagent: {e}"))?;
2090 let hidden = wstore
2091 .instance_set_hidden(&cmd.id, true)
2092 .map_err(|e| format!("hidenamedagent: {e}"))?;
2093 if hidden {
2094 broker.publish(crate::backend::wps::WaveEvent {
2095 event: "namedagents:changed".to_string(),
2096 scopes: vec![],
2097 sender: String::new(),
2098 persist: 0,
2099 data: None,
2100 });
2101 }
2102 Ok(Some(json!({ "hidden": hidden })))
2103 })
2104 }),
2105 );
2106
2107 let wstore = state.wstore.clone();
2122 let filestore = state.filestore.clone();
2123 engine.register_handler(
2124 COMMAND_LIST_RECENT_SESSIONS,
2125 Box::new(move |data, _ctx| {
2126 let wstore = wstore.clone();
2127 let filestore = filestore.clone();
2128 Box::pin(async move {
2129 let cmd: CommandListRecentSessionsData =
2130 serde_json::from_value(data).unwrap_or_default();
2131 let limit = if cmd.limit == 0 {
2132 20
2133 } else {
2134 cmd.limit.min(100)
2135 };
2136 let raw_limit = (limit * 10).max(50).min(500);
2142
2143 let identity_filter = cmd
2151 .identity_id
2152 .as_deref()
2153 .map(|s| s.trim())
2154 .filter(|s| !s.is_empty());
2155
2156 let instances: Vec<AgentInstance> = match wstore.shared_agent_registry() {
2165 Some(reg) => {
2166 let agents_root = wstore.registry_agents_base();
2167 let mut records = reg
2168 .list_active()
2169 .map_err(|e| format!("listrecentsessions: registry: {e}"))?;
2170 if let Some(idf) = identity_filter {
2171 records.retain(|r| r.data.identity_id.as_deref() == Some(idf));
2172 }
2173 records.sort_by(|a, b| {
2179 a.data
2180 .definition_id
2181 .cmp(&b.data.definition_id)
2182 .then_with(|| a.data.instance_name.cmp(&b.data.instance_name))
2183 .then_with(|| {
2184 b.data.last_launched_at_ms.cmp(&a.data.last_launched_at_ms)
2185 })
2186 });
2187 records.dedup_by(|a, b| {
2188 a.data.definition_id == b.data.definition_id
2189 && a.data.instance_name == b.data.instance_name
2190 });
2191 records.sort_by(|a, b| {
2192 b.data.last_launched_at_ms.cmp(&a.data.last_launched_at_ms)
2193 });
2194 records.truncate(raw_limit);
2195 let local = wstore
2203 .instance_list_named(raw_limit, None, identity_filter, true)
2204 .unwrap_or_default();
2205 let mut local_by_key: std::collections::HashMap<
2206 (String, String),
2207 AgentInstance,
2208 > = std::collections::HashMap::new();
2209 for li in local {
2210 let key = (li.definition_id.clone(), li.instance_name.clone());
2211 match local_by_key.get(&key) {
2212 Some(e) if e.started_at >= li.started_at => {}
2213 _ => {
2214 local_by_key.insert(key, li);
2215 }
2216 }
2217 }
2218 let mut out: Vec<AgentInstance> = records
2219 .into_iter()
2220 .map(|rec| {
2221 let d = rec.data;
2222 let key = (d.definition_id.clone(), d.instance_name.clone());
2223 if let Some(li) = local_by_key.get(&key) {
2224 return li.clone();
2225 }
2226 let working_directory = match d.source_agents_base.as_deref() {
2229 Some(src) => std::path::Path::new(src)
2230 .join(&d.working_dir)
2231 .to_string_lossy()
2232 .to_string(),
2233 None => match agents_root.as_ref() {
2234 Some(root) => root
2235 .join(&d.working_dir)
2236 .to_string_lossy()
2237 .to_string(),
2238 None => d.working_dir.clone(),
2239 },
2240 };
2241 AgentInstance {
2242 id: d.instance_id,
2243 definition_id: d.definition_id,
2244 parent_instance_id: String::new(),
2245 block_id: String::new(),
2246 session_id: d.session_id.unwrap_or_default(),
2247 status: "available".to_string(),
2248 github_context: String::new(),
2249 started_at: d.last_launched_at_ms,
2250 ended_at: 0,
2251 created_at: d.created_at_ms,
2252 identity_id: d.identity_id.unwrap_or_default(),
2253 memory_id: d.memory_id.unwrap_or_default(),
2254 instance_name: d.instance_name,
2255 working_directory,
2256 display_hidden: false,
2257 }
2258 })
2259 .collect();
2260 let have_keys: std::collections::HashSet<(String, String)> = out
2266 .iter()
2267 .map(|i| (i.definition_id.clone(), i.instance_name.clone()))
2268 .collect();
2269 for (key, li) in local_by_key {
2270 if !have_keys.contains(&key) {
2271 out.push(li);
2272 }
2273 }
2274 out
2275 }
2276 None => wstore
2277 .instance_list_named(raw_limit, None, identity_filter, true)
2278 .map_err(|e| format!("listrecentsessions: {e}"))?,
2279 };
2280
2281 let defs = wstore
2282 .agent_def_list()
2283 .map_err(|e| format!("listrecentsessions: defs: {e}"))?;
2284 let identities = wstore
2285 .bundle_identity_list()
2286 .map_err(|e| format!("listrecentsessions: identities: {e}"))?;
2287 let memories = wstore
2288 .bundle_memory_list()
2289 .map_err(|e| format!("listrecentsessions: memories: {e}"))?;
2290
2291 let mut rows: Vec<RecentSessionRow> = Vec::with_capacity(instances.len());
2296 for inst in instances {
2297 let def = defs.iter().find(|d| d.id == inst.definition_id);
2298 let identity_name = if inst.identity_id.is_empty() {
2299 "(ambient creds)".to_string()
2300 } else {
2301 identities
2302 .iter()
2303 .find(|i| i.id == inst.identity_id)
2304 .map(|i| i.name.clone())
2305 .unwrap_or_else(|| "(missing identity)".to_string())
2306 };
2307 let memory_name = if inst.memory_id.is_empty() {
2308 "(vanilla CLI)".to_string()
2309 } else {
2310 memories
2311 .iter()
2312 .find(|m| m.id == inst.memory_id)
2313 .map(|m| m.name.clone())
2314 .unwrap_or_else(|| "(missing memory)".to_string())
2315 };
2316
2317 let (has_snapshot, last_active_at, preview, node_count) =
2321 if inst.block_id.is_empty() {
2322 (false, inst.started_at, String::new(), 0usize)
2323 } else {
2324 match filestore.stat(&inst.block_id, "output.state.json") {
2325 Ok(Some(file)) => {
2326 let modts = if file.modts > 0 {
2327 file.modts
2328 } else {
2329 inst.started_at
2330 };
2331 let (preview, node_count) = read_session_preview(
2332 &filestore,
2333 &inst.block_id,
2334 );
2335 (true, modts, preview, node_count)
2336 }
2337 _ => (false, inst.started_at, String::new(), 0usize),
2338 }
2339 };
2340
2341 rows.push(RecentSessionRow {
2342 instance_id: inst.id,
2343 instance_name: inst.instance_name,
2344 definition_id: inst.definition_id.clone(),
2345 definition_name: def
2346 .map(|d| d.name.clone())
2347 .unwrap_or_else(|| "(missing definition)".to_string()),
2348 provider: def.map(|d| d.provider.clone()).unwrap_or_default(),
2349 working_directory: inst.working_directory,
2350 identity_id: inst.identity_id,
2351 identity_name,
2352 memory_id: inst.memory_id,
2353 memory_name,
2354 block_id_hint: inst.block_id,
2355 session_id: inst.session_id,
2361 preview,
2362 node_count,
2363 last_active_at,
2364 has_snapshot,
2365 agent_created_at: def.map(|d| d.created_at).unwrap_or(0),
2366 started_at: inst.started_at,
2367 agent_type: def.map(|d| d.agent_type.clone()).unwrap_or_default(),
2368 });
2369 }
2370
2371 rows.sort_by(|a, b| match (a.has_snapshot, b.has_snapshot) {
2376 (true, true) | (false, false) => {
2377 b.last_active_at.cmp(&a.last_active_at)
2378 }
2379 (true, false) => std::cmp::Ordering::Less,
2380 (false, true) => std::cmp::Ordering::Greater,
2381 });
2382 rows.truncate(limit);
2383
2384 Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
2385 })
2386 }),
2387 );
2388
2389 let wstore = state.wstore.clone();
2392 let broker = state.broker.clone();
2393 engine.register_handler(
2394 COMMAND_FORK_AGENT_DEFINITION,
2395 Box::new(move |data, _ctx| {
2396 let wstore = wstore.clone();
2397 let broker = broker.clone();
2398 Box::pin(async move {
2399 let cmd: CommandForkAgentDefinitionData = serde_json::from_value(data)
2400 .map_err(|e| format!("forkagentdefinition: {e}"))?;
2401
2402 let all_defs = wstore
2404 .agent_def_list()
2405 .map_err(|e| format!("forkagentdefinition: {e}"))?;
2406 let source = all_defs
2407 .iter()
2408 .find(|a| a.id == cmd.source_id)
2409 .cloned()
2410 .ok_or_else(|| format!("forkagentdefinition: source not found: {}", cmd.source_id))?;
2411
2412 let now = SystemTime::now()
2416 .duration_since(UNIX_EPOCH)
2417 .map(|d| d.as_millis() as i64)
2418 .unwrap_or(0);
2419
2420 let fork_name = if cmd.branch_label.is_empty() {
2423 let existing_fork_count = all_defs
2424 .iter()
2425 .filter(|a| a.parent_id == cmd.source_id && a.is_seeded == 0)
2426 .count();
2427 format!("{} #{}", source.name, existing_fork_count + 2)
2428 } else {
2429 cmd.branch_label.clone()
2430 };
2431 let branch_label = if cmd.branch_label.is_empty() {
2432 fork_name.clone()
2433 } else {
2434 cmd.branch_label.clone()
2435 };
2436 let branch_slug_part = crate::backend::storage::store::derive_slug(&branch_label);
2437 let mut fork = AgentDefinition {
2438 id: uuid::Uuid::new_v4().to_string(),
2439 slug: format!("{}-{}", source.slug, branch_slug_part),
2441 name: fork_name,
2442 icon: source.icon.clone(),
2443 provider: source.provider.clone(),
2444 description: source.description.clone(),
2445 working_directory: String::new(), shell: source.shell.clone(),
2447 provider_flags: source.provider_flags.clone(),
2448 auto_start: 0, restart_on_crash: source.restart_on_crash,
2450 idle_timeout_minutes: source.idle_timeout_minutes,
2451 created_at: now,
2452 agent_type: source.agent_type.clone(),
2453 environment: source.environment.clone(),
2454 agent_bus_id: String::new(), is_seeded: 0,
2456 accounts: String::new(),
2457 parent_id: source.id.clone(),
2458 branch_label: branch_label.clone(),
2459 updated_at: now,
2460 user_hidden: 0,
2461 container_image: source.container_image.clone(),
2464 container_volumes: source.container_volumes.clone(),
2465 container_name: String::new(),
2466 };
2467 wstore
2468 .agent_def_insert(&mut fork)
2469 .map_err(|e| format!("forkagentdefinition: {e}"))?;
2470
2471 let source_contents = wstore
2475 .agent_content_get_all(&source.id)
2476 .map_err(|e| format!("forkagentdefinition content: {e}"))?;
2477 for c in source_contents {
2478 let new_content = AgentContent {
2479 agent_id: fork.id.clone(),
2480 content_type: c.content_type,
2481 content: c.content,
2482 updated_at: now,
2483 };
2484 wstore
2485 .agent_content_set(&new_content)
2486 .map_err(|e| format!("forkagentdefinition content: {e}"))?;
2487 }
2488 let source_skills = wstore
2489 .agent_skill_list(&source.id)
2490 .map_err(|e| format!("forkagentdefinition skills: {e}"))?;
2491 for s in source_skills {
2492 let new_skill = AgentSkill {
2493 id: uuid::Uuid::new_v4().to_string(),
2494 agent_id: fork.id.clone(),
2495 name: s.name,
2496 trigger: s.trigger,
2497 skill_type: s.skill_type,
2498 description: s.description,
2499 content: s.content,
2500 created_at: now,
2501 };
2502 wstore
2503 .agent_skill_insert(&new_skill)
2504 .map_err(|e| format!("forkagentdefinition skill: {e}"))?;
2505 }
2506
2507 broker.publish(crate::backend::wps::WaveEvent {
2508 event: "agents:changed".to_string(),
2509 scopes: vec![],
2510 sender: String::new(),
2511 persist: 0,
2512 data: None,
2513 });
2514
2515 Ok(Some(serde_json::to_value(&fork).unwrap_or_default()))
2516 })
2517 }),
2518 );
2519
2520 let wstore_sug = state.wstore.clone();
2523 engine.register_handler(
2524 COMMAND_FORK_AGENT_DEFINITION_SUGGEST,
2525 Box::new(move |data, _ctx| {
2526 let wstore = wstore_sug.clone();
2527 Box::pin(async move {
2528 let cmd: CommandForkAgentDefinitionSuggestData = serde_json::from_value(data)
2529 .map_err(|e| format!("forkagentdefinitionsuggest: {e}"))?;
2530
2531 let all = wstore
2532 .agent_def_list()
2533 .map_err(|e| format!("forkagentdefinitionsuggest: {e}"))?;
2534 let source = all
2535 .iter()
2536 .find(|a| a.id == cmd.source_id)
2537 .ok_or_else(|| format!("forkagentdefinitionsuggest: source not found: {}", cmd.source_id))?;
2538
2539 let existing_fork_count = all
2540 .iter()
2541 .filter(|a| a.parent_id == cmd.source_id && a.is_seeded == 0)
2542 .count();
2543 let suggested_label = format!("{} #{}", source.name, existing_fork_count + 2);
2544
2545 let result = ForkAgentDefinitionSuggestResult { suggested_label };
2546 Ok(Some(serde_json::to_value(&result).unwrap_or_default()))
2547 })
2548 }),
2549 );
2550
2551 register_agent_session_handlers(engine, state);
2552 register_v7_handlers(engine, state);
2553}
2554
2555fn register_agent_session_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
2562 let filestore = state.filestore.clone();
2564 engine.register_handler(
2565 COMMAND_AGENT_SESSION_READ,
2566 Box::new(move |data, _ctx| {
2567 let filestore = filestore.clone();
2568 Box::pin(async move {
2569 let cmd: CommandAgentSessionReadData = serde_json::from_value(data)
2570 .map_err(|e| format!("agent:session:read: {e}"))?;
2571 let (content, modts) =
2572 crate::backend::agent_session::read_session_state(&filestore, &cmd.definition_id)
2573 .map_err(|e| format!("agent:session:read: {e}"))?;
2574 Ok(Some(
2575 serde_json::to_value(&AgentSessionReadResult { content, modts })
2576 .unwrap_or_default(),
2577 ))
2578 })
2579 }),
2580 );
2581
2582 let filestore = state.filestore.clone();
2584 engine.register_handler(
2585 COMMAND_AGENT_SESSION_WRITE_STATE,
2586 Box::new(move |data, _ctx| {
2587 let filestore = filestore.clone();
2588 Box::pin(async move {
2589 let cmd: CommandAgentSessionWriteStateData = serde_json::from_value(data)
2590 .map_err(|e| format!("agent:session:write_state: {e}"))?;
2591 let bytes = cmd.content.as_bytes();
2592 let bytes_written = bytes.len() as u64;
2593 crate::backend::agent_session::write_session_state(
2594 &filestore,
2595 &cmd.definition_id,
2596 bytes,
2597 )
2598 .map_err(|e| format!("agent:session:write_state: {e}"))?;
2599 Ok(Some(
2600 serde_json::to_value(&AgentSessionWriteStateResult { bytes_written })
2601 .unwrap_or_default(),
2602 ))
2603 })
2604 }),
2605 );
2606
2607 let filestore = state.filestore.clone();
2609 engine.register_handler(
2610 COMMAND_AGENT_SESSION_APPEND_OUTPUT,
2611 Box::new(move |data, _ctx| {
2612 let filestore = filestore.clone();
2613 Box::pin(async move {
2614 let cmd: CommandAgentSessionAppendOutputData = serde_json::from_value(data)
2615 .map_err(|e| format!("agent:session:append_output: {e}"))?;
2616 let bytes_written = crate::backend::agent_session::append_session_output(
2617 &filestore,
2618 &cmd.definition_id,
2619 &cmd.line,
2620 )
2621 .map_err(|e| format!("agent:session:append_output: {e}"))?;
2622 Ok(Some(
2623 serde_json::to_value(&AgentSessionAppendOutputResult { bytes_written })
2624 .unwrap_or_default(),
2625 ))
2626 })
2627 }),
2628 );
2629
2630 let filestore = state.filestore.clone();
2632 engine.register_handler(
2633 COMMAND_AGENT_SESSION_ARCHIVE,
2634 Box::new(move |data, _ctx| {
2635 let filestore = filestore.clone();
2636 Box::pin(async move {
2637 let cmd: CommandAgentSessionArchiveData = serde_json::from_value(data)
2638 .map_err(|e| format!("agent:session:archive: {e}"))?;
2639 let result =
2640 crate::backend::agent_session::archive_session(&filestore, &cmd.definition_id)
2641 .map_err(|e| format!("agent:session:archive: {e}"))?;
2642 let (archive_zoneid, archived_at_ms) = match result {
2643 Some((z, ts)) => (z, ts),
2644 None => (String::new(), 0),
2645 };
2646 Ok(Some(
2647 serde_json::to_value(&AgentSessionArchiveResult {
2648 archive_zoneid,
2649 archived_at_ms,
2650 })
2651 .unwrap_or_default(),
2652 ))
2653 })
2654 }),
2655 );
2656
2657 let filestore = state.filestore.clone();
2659 engine.register_handler(
2660 COMMAND_AGENT_SESSION_LIST_ARCHIVES,
2661 Box::new(move |data, _ctx| {
2662 let filestore = filestore.clone();
2663 Box::pin(async move {
2664 let cmd: CommandAgentSessionListArchivesData =
2665 serde_json::from_value(data).unwrap_or_default();
2666 let summaries = crate::backend::agent_session::list_archives(
2667 &filestore,
2668 &cmd.definition_id,
2669 cmd.limit,
2670 )
2671 .map_err(|e| format!("agent:session:list_archives: {e}"))?;
2672 let rows: Vec<AgentArchiveRow> = summaries
2673 .into_iter()
2674 .map(|s| AgentArchiveRow {
2675 archive_zoneid: s.archive_zoneid,
2676 archived_at_ms: s.archived_at_ms,
2677 preview: s.preview,
2678 node_count: s.node_count,
2679 })
2680 .collect();
2681 Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
2682 })
2683 }),
2684 );
2685}
2686
2687fn register_v7_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
2694 let wstore = state.wstore.clone();
2697 engine.register_handler(
2698 COMMAND_LIST_IDENTITY_BUNDLES,
2699 Box::new(move |_data, _ctx| {
2700 let wstore = wstore.clone();
2701 Box::pin(async move {
2702 let bundles = wstore
2703 .bundle_identity_list()
2704 .map_err(|e| format!("listidentitybundles: {e}"))?;
2705 Ok(Some(serde_json::to_value(&bundles).unwrap_or_default()))
2706 })
2707 }),
2708 );
2709
2710 let wstore = state.wstore.clone();
2711 engine.register_handler(
2712 COMMAND_GET_IDENTITY_BUNDLE,
2713 Box::new(move |data, _ctx| {
2714 let wstore = wstore.clone();
2715 Box::pin(async move {
2716 let cmd: CommandGetIdentityBundleData = serde_json::from_value(data)
2717 .map_err(|e| format!("getidentitybundle: {e}"))?;
2718 match wstore
2719 .bundle_identity_get(&cmd.id)
2720 .map_err(|e| format!("getidentitybundle: {e}"))?
2721 {
2722 Some(b) => Ok(Some(serde_json::to_value(&b).unwrap_or_default())),
2723 None => Err(format!("getidentitybundle: not found id={}", cmd.id)),
2724 }
2725 })
2726 }),
2727 );
2728
2729 let wstore = state.wstore.clone();
2730 let broker = state.broker.clone();
2731 engine.register_handler(
2732 COMMAND_UPSERT_IDENTITY_BUNDLE,
2733 Box::new(move |data, _ctx| {
2734 let wstore = wstore.clone();
2735 let broker = broker.clone();
2736 Box::pin(async move {
2737 let mut bundle: Identity = serde_json::from_value(data)
2738 .map_err(|e| format!("upsertidentitybundle: {e}"))?;
2739 if bundle.is_blank || bundle.id == "blank" {
2745 return Err(
2746 "upsertidentitybundle: cannot mutate the blank singleton".to_string(),
2747 );
2748 }
2749 if bundle.id.is_empty() {
2750 bundle.id = uuid::Uuid::new_v4().to_string();
2751 }
2752 let now = SystemTime::now()
2753 .duration_since(UNIX_EPOCH)
2754 .map(|d| d.as_millis() as i64)
2755 .unwrap_or(0);
2756 if bundle.created_at == 0 {
2757 bundle.created_at = now;
2758 }
2759 bundle.updated_at = now;
2760 wstore
2761 .bundle_identity_upsert(&bundle)
2762 .map_err(|e| format!("upsertidentitybundle: {e}"))?;
2763 broker.publish(crate::backend::wps::WaveEvent {
2764 event: "identitybundles:changed".to_string(),
2765 scopes: vec![],
2766 sender: String::new(),
2767 persist: 0,
2768 data: None,
2769 });
2770 Ok(Some(serde_json::to_value(&bundle).unwrap_or_default()))
2771 })
2772 }),
2773 );
2774
2775 let wstore = state.wstore.clone();
2776 let broker = state.broker.clone();
2777 engine.register_handler(
2778 COMMAND_DELETE_IDENTITY_BUNDLE,
2779 Box::new(move |data, _ctx| {
2780 let wstore = wstore.clone();
2781 let broker = broker.clone();
2782 Box::pin(async move {
2783 let cmd: CommandDeleteIdentityBundleData = serde_json::from_value(data)
2784 .map_err(|e| format!("deleteidentitybundle: {e}"))?;
2785 let deleted = wstore
2786 .bundle_identity_delete(&cmd.id)
2787 .map_err(|e| format!("deleteidentitybundle: {e}"))?;
2788 if deleted {
2789 broker.publish(crate::backend::wps::WaveEvent {
2790 event: "identitybundles:changed".to_string(),
2791 scopes: vec![],
2792 sender: String::new(),
2793 persist: 0,
2794 data: None,
2795 });
2796 }
2797 Ok(Some(json!({ "deleted": deleted })))
2798 })
2799 }),
2800 );
2801
2802 let wstore = state.wstore.clone();
2805 let broker = state.broker.clone();
2806 engine.register_handler(
2807 COMMAND_BIND_IDENTITY_ACCOUNT,
2808 Box::new(move |data, _ctx| {
2809 let wstore = wstore.clone();
2810 let broker = broker.clone();
2811 Box::pin(async move {
2812 let cmd: CommandBindIdentityAccountData = serde_json::from_value(data)
2813 .map_err(|e| format!("bindidentityaccount: {e}"))?;
2814 wstore
2815 .bundle_identity_bind(&cmd.identity_id, &cmd.provider, &cmd.account_id)
2816 .map_err(|e| format!("bindidentityaccount: {e}"))?;
2817 broker.publish(crate::backend::wps::WaveEvent {
2818 event: format!("identitybundlebindings:changed:{}", cmd.identity_id),
2819 scopes: vec![],
2820 sender: String::new(),
2821 persist: 0,
2822 data: None,
2823 });
2824 Ok(None)
2825 })
2826 }),
2827 );
2828
2829 let wstore = state.wstore.clone();
2830 let broker = state.broker.clone();
2831 engine.register_handler(
2832 COMMAND_UNBIND_IDENTITY_ACCOUNT,
2833 Box::new(move |data, _ctx| {
2834 let wstore = wstore.clone();
2835 let broker = broker.clone();
2836 Box::pin(async move {
2837 let cmd: CommandUnbindIdentityAccountData = serde_json::from_value(data)
2838 .map_err(|e| format!("unbindidentityaccount: {e}"))?;
2839 let removed = wstore
2840 .bundle_identity_unbind(&cmd.identity_id, &cmd.provider)
2841 .map_err(|e| format!("unbindidentityaccount: {e}"))?;
2842 if removed {
2843 broker.publish(crate::backend::wps::WaveEvent {
2844 event: format!("identitybundlebindings:changed:{}", cmd.identity_id),
2845 scopes: vec![],
2846 sender: String::new(),
2847 persist: 0,
2848 data: None,
2849 });
2850 }
2851 Ok(Some(json!({ "unbound": removed })))
2852 })
2853 }),
2854 );
2855
2856 let wstore = state.wstore.clone();
2857 engine.register_handler(
2858 COMMAND_LIST_IDENTITY_BINDINGS,
2859 Box::new(move |data, _ctx| {
2860 let wstore = wstore.clone();
2861 Box::pin(async move {
2862 let cmd: CommandListIdentityBindingsData = serde_json::from_value(data)
2863 .map_err(|e| format!("listidentitybindings: {e}"))?;
2864 let bindings = wstore
2865 .bundle_identity_bindings(&cmd.identity_id)
2866 .map_err(|e| format!("listidentitybindings: {e}"))?;
2867 Ok(Some(serde_json::to_value(&bindings).unwrap_or_default()))
2868 })
2869 }),
2870 );
2871
2872 let wstore = state.wstore.clone();
2875 engine.register_handler(
2876 COMMAND_LIST_MEMORIES,
2877 Box::new(move |_data, _ctx| {
2878 let wstore = wstore.clone();
2879 Box::pin(async move {
2880 let memories = wstore
2881 .bundle_memory_list()
2882 .map_err(|e| format!("listmemories: {e}"))?;
2883 Ok(Some(serde_json::to_value(&memories).unwrap_or_default()))
2884 })
2885 }),
2886 );
2887
2888 let wstore = state.wstore.clone();
2889 engine.register_handler(
2890 COMMAND_GET_MEMORY,
2891 Box::new(move |data, _ctx| {
2892 let wstore = wstore.clone();
2893 Box::pin(async move {
2894 let cmd: CommandGetMemoryData = serde_json::from_value(data)
2895 .map_err(|e| format!("getmemory: {e}"))?;
2896 match wstore
2897 .bundle_memory_get(&cmd.id)
2898 .map_err(|e| format!("getmemory: {e}"))?
2899 {
2900 Some(m) => Ok(Some(serde_json::to_value(&m).unwrap_or_default())),
2901 None => Err(format!("getmemory: not found id={}", cmd.id)),
2902 }
2903 })
2904 }),
2905 );
2906
2907 let wstore = state.wstore.clone();
2908 let broker = state.broker.clone();
2909 engine.register_handler(
2910 COMMAND_UPSERT_MEMORY,
2911 Box::new(move |data, _ctx| {
2912 let wstore = wstore.clone();
2913 let broker = broker.clone();
2914 Box::pin(async move {
2915 let mut memory: Memory = serde_json::from_value(data)
2916 .map_err(|e| format!("upsertmemory: {e}"))?;
2917 if memory.is_blank || memory.id == "blank" {
2921 return Err("upsertmemory: cannot mutate the blank singleton".to_string());
2922 }
2923 if memory.id.is_empty() {
2924 memory.id = uuid::Uuid::new_v4().to_string();
2925 }
2926 let now = SystemTime::now()
2927 .duration_since(UNIX_EPOCH)
2928 .map(|d| d.as_millis() as i64)
2929 .unwrap_or(0);
2930 if memory.created_at == 0 {
2931 memory.created_at = now;
2932 }
2933 memory.updated_at = now;
2934 wstore
2935 .bundle_memory_upsert(&memory)
2936 .map_err(|e| format!("upsertmemory: {e}"))?;
2937 broker.publish(crate::backend::wps::WaveEvent {
2938 event: "memories:changed".to_string(),
2939 scopes: vec![],
2940 sender: String::new(),
2941 persist: 0,
2942 data: None,
2943 });
2944 Ok(Some(serde_json::to_value(&memory).unwrap_or_default()))
2945 })
2946 }),
2947 );
2948
2949 let wstore = state.wstore.clone();
2950 let broker = state.broker.clone();
2951 engine.register_handler(
2952 COMMAND_DELETE_MEMORY,
2953 Box::new(move |data, _ctx| {
2954 let wstore = wstore.clone();
2955 let broker = broker.clone();
2956 Box::pin(async move {
2957 let cmd: CommandDeleteMemoryData = serde_json::from_value(data)
2958 .map_err(|e| format!("deletememory: {e}"))?;
2959 let deleted = wstore
2960 .bundle_memory_delete(&cmd.id)
2961 .map_err(|e| format!("deletememory: {e}"))?;
2962 if deleted {
2963 broker.publish(crate::backend::wps::WaveEvent {
2964 event: "memories:changed".to_string(),
2965 scopes: vec![],
2966 sender: String::new(),
2967 persist: 0,
2968 data: None,
2969 });
2970 }
2971 Ok(Some(json!({ "deleted": deleted })))
2972 })
2973 }),
2974 );
2975
2976 let wstore = state.wstore.clone();
2977 let broker = state.broker.clone();
2978 engine.register_handler(
2979 COMMAND_REORDER_GLOBAL_BRAIN,
2980 Box::new(move |data, _ctx| {
2981 let wstore = wstore.clone();
2982 let broker = broker.clone();
2983 Box::pin(async move {
2984 let cmd: CommandReorderGlobalBrainData = serde_json::from_value(data)
2985 .map_err(|e| format!("reorderglobalbrain: {e}"))?;
2986 let updated = wstore
2987 .bundle_memory_reorder(&cmd.ids)
2988 .map_err(|e| format!("reorderglobalbrain: {e}"))?;
2989 broker.publish(crate::backend::wps::WaveEvent {
2990 event: "memories:changed".to_string(),
2991 scopes: vec![],
2992 sender: String::new(),
2993 persist: 0,
2994 data: None,
2995 });
2996 Ok(Some(json!({ "updated": updated })))
2997 })
2998 }),
2999 );
3000}
3001
3002pub fn register_agent_input_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
3003 let wstore_spawn = state.wstore.clone();
3005 let broker_spawn = state.broker.clone();
3006 let event_bus_spawn = state.event_bus.clone();
3007 let filestore_spawn = state.filestore.clone();
3008 engine.register_handler(
3009 COMMAND_SUBPROCESS_SPAWN,
3010 Box::new(move |data, _ctx| {
3011 let wstore = wstore_spawn.clone();
3012 let broker = broker_spawn.clone();
3013 let event_bus = event_bus_spawn.clone();
3014 let filestore = filestore_spawn.clone();
3015 Box::pin(async move {
3016 let cmd: CommandSubprocessSpawnData = serde_json::from_value(data)
3017 .map_err(|e| format!("subprocessspawn: {e}"))?;
3018 tracing::info!(
3019 block_id = %cmd.blockid,
3020 cli = %cmd.cli_command,
3021 "SubprocessSpawn"
3022 );
3023
3024 let ctrl = match blockcontroller::get_controller(&cmd.blockid) {
3026 Some(c) if c.controller_type() == blockcontroller::BLOCK_CONTROLLER_SUBPROCESS => c,
3027 _ => {
3028 let ctrl = blockcontroller::subprocess::SubprocessController::new(
3030 cmd.tabid.clone(),
3031 cmd.blockid.clone(),
3032 Some(broker),
3033 Some(event_bus),
3034 Some(wstore),
3035 Some(filestore),
3036 );
3037 let ctrl = std::sync::Arc::new(ctrl);
3038 ctrl.set_self_ref();
3039 blockcontroller::register_controller(&cmd.blockid, ctrl.clone());
3040 ctrl as std::sync::Arc<dyn blockcontroller::Controller>
3041 }
3042 };
3043
3044 let subprocess_ctrl = ctrl
3046 .as_any()
3047 .downcast_ref::<blockcontroller::subprocess::SubprocessController>()
3048 .ok_or_else(|| "controller is not a SubprocessController".to_string())?;
3049
3050 let config = blockcontroller::subprocess::SubprocessSpawnConfig {
3051 cli_command: cmd.cli_command,
3052 cli_args: cmd.cli_args,
3053 working_dir: cmd.working_dir,
3054 env_vars: cmd.env_vars,
3055 message: cmd.message,
3056 resume_flag: "--resume".to_string(),
3057 session_id_field: "session_id".to_string(),
3058 message_id: None,
3059 session_id: None,
3064 };
3065 subprocess_ctrl.spawn_turn(config)?;
3066 Ok(None)
3067 })
3068 }),
3069 );
3070
3071 let wstore_ai = state.wstore.clone();
3073 let auth_key_ai = state.auth_key.clone();
3077 let broker_ai = state.broker.clone();
3081 let container_manager_ai = state.container_manager.clone();
3084 engine.register_handler(
3085 COMMAND_AGENT_INPUT,
3086 Box::new(move |data, _ctx| {
3087 let wstore = wstore_ai.clone();
3088 let auth_key = auth_key_ai.clone();
3089 let broker = broker_ai.clone();
3090 let container_manager = container_manager_ai.clone();
3091 Box::pin(async move {
3092 let cmd: CommandAgentInputData = serde_json::from_value(data)
3093 .map_err(|e| format!("agentinput: {e}"))?;
3094 tracing::info!(block_id = %cmd.blockid, "AgentInput");
3095
3096 let ctrl = blockcontroller::get_controller(&cmd.blockid)
3097 .ok_or_else(|| format!("no controller for block {}", cmd.blockid))?;
3098
3099 let block: Block = wstore
3101 .get(&cmd.blockid)
3102 .map_err(|e| format!("agentinput: load block: {e}"))?
3103 .ok_or_else(|| format!("block {} not found", cmd.blockid))?;
3104
3105 let cli_command = crate::backend::obj::meta_get_string(
3106 &block.meta, "cmd", "claude",
3107 );
3108 let cli_args: Vec<String> = match block.meta.get("cmd:args") {
3109 Some(serde_json::Value::Array(arr)) => arr
3110 .iter()
3111 .filter_map(|v| v.as_str().map(|s| s.to_string()))
3112 .collect(),
3113 _ => vec![
3114 "-p".to_string(),
3115 "--input-format".to_string(),
3116 "stream-json".to_string(),
3117 "--output-format".to_string(),
3118 "stream-json".to_string(),
3119 ],
3120 };
3121 let working_dir = crate::backend::obj::meta_get_string(
3122 &block.meta, "cmd:cwd", "",
3123 );
3124 let mut env_vars: std::collections::HashMap<String, String> = match block.meta.get("cmd:env") {
3125 Some(serde_json::Value::Object(obj)) => obj
3126 .iter()
3127 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
3128 .collect(),
3129 _ => std::collections::HashMap::new(),
3130 };
3131 env_vars = crate::identity::resolver::inject_identity_env_async(
3141 wstore.clone(),
3142 Some(broker.clone()),
3143 cmd.blockid.clone(),
3144 env_vars,
3145 )
3146 .await;
3147 crate::server::muxbus_handlers::inject_muxbus_env(&wstore, &mut env_vars);
3151 env_vars.insert("AGENTMUX_AUTH_KEY".to_string(), auth_key.clone());
3166 env_vars.insert("AGENTMUX_BLOCKID".to_string(), cmd.blockid.clone());
3171 if !env_vars.contains_key("AGENTMUX_AGENT_ID") {
3175 let agent_display_name = crate::backend::obj::meta_get_string(
3176 &block.meta, "agentName", "",
3177 );
3178 if !agent_display_name.is_empty() {
3179 env_vars.insert("AGENTMUX_AGENT_ID".to_string(), agent_display_name);
3180 }
3181 }
3182 {
3189 let existing = env_vars
3190 .get("PATH")
3191 .cloned()
3192 .or_else(|| std::env::var("PATH").ok())
3193 .unwrap_or_default();
3194 let sep = if cfg!(windows) { ";" } else { ":" };
3195 let mut extras: Vec<String> = Vec::new();
3196 if let Some(d) = crate::backend::tool_store::bundled_tools_dir() {
3197 if d.exists() {
3198 extras.push(d.to_string_lossy().into_owned());
3199 }
3200 }
3201 if let Some(d) = crate::backend::tool_store::user_tools_dir() {
3202 if d.exists() {
3203 extras.push(d.to_string_lossy().into_owned());
3204 }
3205 }
3206 if !extras.is_empty() {
3207 let new_path = format!("{}{}{}", extras.join(sep), sep, existing);
3208 env_vars.insert("PATH".to_string(), new_path);
3209 }
3210 }
3211
3212 let session_id_field = crate::backend::obj::meta_get_string(
3213 &block.meta, "agent:session_id_field", "session_id",
3214 );
3215
3216 if let Some(persistent_ctrl) = ctrl
3218 .as_any()
3219 .downcast_ref::<blockcontroller::persistent::PersistentSubprocessController>()
3220 {
3221 let agent_mode = crate::backend::obj::meta_get_string(&block.meta, "agentMode", "host");
3225 if agent_mode == "container" {
3226 return Err("container agents require a subprocess controller; this provider uses a persistent controller".to_string());
3227 }
3228 let resume_flag = crate::backend::obj::meta_get_string(
3235 &block.meta, "agent:resume_flag", "--resume",
3236 );
3237 let persisted_session_id = crate::backend::obj::meta_get_string(
3238 &block.meta, "agent:sessionid", "",
3239 );
3240 let config = blockcontroller::persistent::PersistentSpawnConfig {
3241 cli_command,
3242 cli_args,
3243 working_dir,
3244 env_vars,
3245 session_id_field,
3246 resume_flag,
3247 session_id: persisted_session_id,
3248 message_id: cmd.message_id.clone(),
3249 };
3250 persistent_ctrl.send_message(cmd.message, config)?;
3251 } else if let Some(subprocess_ctrl) = ctrl
3252 .as_any()
3253 .downcast_ref::<blockcontroller::subprocess::SubprocessController>()
3254 {
3255 let resume_flag = crate::backend::obj::meta_get_string(
3256 &block.meta, "agent:resume_flag", "--resume",
3257 );
3258 let persisted_session_id = crate::backend::obj::meta_get_string(
3264 &block.meta, "agent:sessionid", "",
3265 );
3266
3267 let agent_mode = crate::backend::obj::meta_get_string(
3270 &block.meta, "agentMode", "host",
3271 );
3272 if agent_mode == "container" {
3273 let cm = container_manager.as_deref()
3274 .ok_or_else(|| "Docker not available on this host; cannot start container agent".to_string())?;
3275 let container_image = crate::backend::obj::meta_get_string(
3276 &block.meta, "agent:container_image", "ghcr.io/agentmuxai/agent-claude:latest",
3277 );
3278 let agent_id = crate::backend::obj::meta_get_string(
3280 &block.meta, "agentId", "",
3281 );
3282 let container_name = crate::backend::container::container_name_for_slug(&agent_id);
3283 let volumes_json = crate::backend::obj::meta_get_string(
3284 &block.meta, "agent:container_volumes", "[]",
3285 );
3286 let volumes: Vec<String> = serde_json::from_str(&volumes_json).unwrap_or_default();
3287
3288 cm.ensure_running(&container_name, &container_image, &volumes, &[]).await
3290 .map_err(|e| format!("container ensure_running failed: {e}"))?;
3291
3292 tracing::info!(
3293 block_id = %cmd.blockid,
3294 container = %container_name,
3295 image = %container_image,
3296 "container agent turn: bollard exec (env via Docker socket, not argv)",
3297 );
3298
3299 let container_command = crate::backend::obj::meta_get_string(
3314 &block.meta, "agent:container_command", "claude",
3315 );
3316 let mut base_cmd = vec![container_command];
3317 base_cmd.extend(cli_args);
3318
3319 let config = blockcontroller::subprocess::SubprocessSpawnConfig {
3320 cli_command: String::new(), cli_args: vec![], working_dir: String::new(), env_vars,
3324 message: cmd.message,
3325 resume_flag,
3326 session_id_field,
3327 message_id: cmd.message_id,
3328 session_id: if persisted_session_id.is_empty() {
3329 None
3330 } else {
3331 Some(persisted_session_id)
3332 },
3333 };
3334 subprocess_ctrl.spawn_container_turn(cm.clone(), container_name, base_cmd, config)?;
3335 } else {
3336 let config = blockcontroller::subprocess::SubprocessSpawnConfig {
3338 cli_command,
3339 cli_args,
3340 working_dir,
3341 env_vars,
3342 message: cmd.message,
3343 resume_flag,
3344 session_id_field,
3345 message_id: cmd.message_id,
3346 session_id: if persisted_session_id.is_empty() {
3347 None
3348 } else {
3349 Some(persisted_session_id)
3350 },
3351 };
3352 subprocess_ctrl.spawn_turn(config)?;
3353 }
3354 } else {
3355 return Err("controller is not a SubprocessController or PersistentSubprocessController".to_string());
3356 }
3357
3358 let agent_name = crate::backend::obj::meta_get_string(
3368 &block.meta, "agentName", "",
3369 );
3370 if !agent_name.is_empty() {
3371 let registered = crate::backend::reactive::handler::get_global_handler()
3372 .register_agent(&agent_name, &cmd.blockid, None);
3373 if registered.is_ok() {
3374 if let Some(sub) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
3375 sub.add_agent(&agent_name);
3376 }
3377 }
3378 }
3379
3380 Ok(None)
3381 })
3382 }),
3383 );
3384
3385 engine.register_handler(
3387 COMMAND_AGENT_STOP,
3388 Box::new(|data, _ctx| {
3389 Box::pin(async move {
3390 let cmd: CommandAgentStopData = serde_json::from_value(data)
3391 .map_err(|e| format!("agentstop: {e}"))?;
3392 tracing::info!(block_id = %cmd.blockid, force = cmd.force, "AgentStop");
3393 match blockcontroller::get_controller(&cmd.blockid) {
3394 Some(ctrl) => {
3395 ctrl.stop(!cmd.force, blockcontroller::STATUS_DONE)?;
3396 let handler = crate::backend::reactive::handler::get_global_handler();
3400 let agent_name = handler.agent_id_for_block(&cmd.blockid);
3401 handler.unregister_block(&cmd.blockid);
3402 if let (Some(sub), Some(name)) = (
3403 crate::muxbus::cloud_subscriber::get_global_subscriber(),
3404 agent_name,
3405 ) {
3406 sub.remove_agent(&name);
3407 }
3408 Ok(None)
3409 }
3410 None => Ok(None),
3411 }
3412 })
3413 }),
3414 );
3415}
3416
3417fn read_session_preview(
3432 filestore: &crate::backend::storage::filestore::FileStore,
3433 block_id: &str,
3434) -> (String, usize) {
3435 let bytes = match filestore.read_file(block_id, "output.state.json") {
3436 Ok(Some(b)) => b,
3437 _ => return (String::new(), 0),
3438 };
3439 if bytes.len() > 4 * 1024 * 1024 {
3444 tracing::warn!(
3445 block_id = %block_id,
3446 size = bytes.len(),
3447 "listrecentsessions: snapshot too large; skipping preview"
3448 );
3449 return (String::new(), 0);
3450 }
3451 let json: serde_json::Value = match serde_json::from_slice(&bytes) {
3452 Ok(v) => v,
3453 Err(_) => return (String::new(), 0),
3454 };
3455 let nodes = match json.get("nodes").and_then(|v| v.as_array()) {
3456 Some(a) => a,
3457 None => return (String::new(), 0),
3458 };
3459 let node_count = nodes.len();
3460 let mut preview = String::new();
3466 for node in nodes {
3467 let ty = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
3468 if ty != "user_message" {
3469 continue;
3470 }
3471 let msg = node
3472 .get("message")
3473 .and_then(|v| v.as_str())
3474 .unwrap_or("")
3475 .trim();
3476 if msg.is_empty() {
3477 continue;
3478 }
3479 if preview.is_empty() && msg.starts_with("# Session Context") {
3480 preview = collapse_preview(msg);
3482 continue;
3483 }
3484 preview = collapse_preview(msg);
3485 break;
3486 }
3487 (preview, node_count)
3488}
3489
3490fn collapse_preview(s: &str) -> String {
3493 const MAX_CHARS: usize = 240;
3494 let mut buf = String::with_capacity(s.len().min(MAX_CHARS + 4));
3495 let mut prev_space = false;
3496 for ch in s.chars() {
3497 if buf.chars().count() >= MAX_CHARS {
3498 buf.push('\u{2026}'); return buf;
3500 }
3501 if ch.is_whitespace() {
3502 if !prev_space && !buf.is_empty() {
3503 buf.push(' ');
3504 prev_space = true;
3505 }
3506 } else {
3507 buf.push(ch);
3508 prev_space = false;
3509 }
3510 }
3511 buf
3512}
3513
3514#[cfg(test)]
3515mod recent_sessions_tests {
3516 use super::*;
3517 use crate::backend::storage::filestore::FileStore;
3518
3519 fn fresh_filestore() -> std::sync::Arc<FileStore> {
3520 std::sync::Arc::new(FileStore::open_in_memory().unwrap())
3521 }
3522
3523 fn write_snapshot(fs: &FileStore, block_id: &str, body: &str) {
3524 let meta: crate::backend::storage::filestore::FileMeta =
3527 std::collections::HashMap::new();
3528 let opts = crate::backend::storage::filestore::FileOpts::default();
3529 fs.make_file(block_id, "output.state.json", meta, opts)
3530 .expect("make_file");
3531 fs.write_file(block_id, "output.state.json", body.as_bytes())
3532 .expect("write_file");
3533 }
3534
3535 #[test]
3536 fn collapse_preview_strips_newlines_and_caps_length() {
3537 let s = "hello\n\nworld\n next line";
3538 assert_eq!(collapse_preview(s), "hello world next line");
3539 let long: String = "a".repeat(500);
3540 let out = collapse_preview(&long);
3541 assert!(out.ends_with('\u{2026}'));
3543 assert!(out.chars().count() <= 241);
3544 }
3545
3546 #[test]
3547 fn read_session_preview_missing_returns_zero() {
3548 let fs = fresh_filestore();
3549 let (preview, count) = read_session_preview(&fs, "no-such-block");
3550 assert_eq!(preview, "");
3551 assert_eq!(count, 0);
3552 }
3553
3554 #[test]
3555 fn read_session_preview_extracts_first_user_message_skipping_context() {
3556 let fs = fresh_filestore();
3557 let snapshot = serde_json::json!({
3560 "schemaVersion": 1,
3561 "savedAt": "2026-05-23T08:00:00Z",
3562 "highWaterMark": 169,
3563 "historyOffset": 0,
3564 "nodes": [
3565 {
3566 "type": "user_message",
3567 "id": "u0",
3568 "timestamp": 0,
3569 "collapsed": false,
3570 "summary": "👤 User Message",
3571 "message": "# Session Context\nIdentity: Claude\n## Description\nStartup boilerplate"
3572 },
3573 { "type": "markdown", "id": "m0", "content": "ack" },
3574 {
3575 "type": "user_message",
3576 "id": "u1",
3577 "timestamp": 100,
3578 "collapsed": false,
3579 "summary": "👤 User Message",
3580 "message": "check the agentmuxai/agentmux history, get the latest code"
3581 }
3582 ]
3583 });
3584 write_snapshot(&fs, "blk-1", &snapshot.to_string());
3585 let (preview, count) = read_session_preview(&fs, "blk-1");
3586 assert_eq!(count, 3);
3587 assert!(preview.starts_with("check the agentmuxai/agentmux"));
3588 }
3589
3590 #[test]
3591 fn read_session_preview_falls_back_to_session_context_when_only_one() {
3592 let fs = fresh_filestore();
3593 let snapshot = serde_json::json!({
3594 "schemaVersion": 1,
3595 "nodes": [
3596 {
3597 "type": "user_message",
3598 "id": "u0",
3599 "message": "# Session Context\nIdentity: Claude\nStartup boilerplate"
3600 }
3601 ]
3602 });
3603 write_snapshot(&fs, "blk-2", &snapshot.to_string());
3604 let (preview, count) = read_session_preview(&fs, "blk-2");
3605 assert_eq!(count, 1);
3606 assert!(preview.starts_with("# Session Context"));
3608 }
3609
3610 #[test]
3611 fn read_session_preview_handles_malformed_json() {
3612 let fs = fresh_filestore();
3613 write_snapshot(&fs, "blk-3", "not valid json {");
3614 let (preview, count) = read_session_preview(&fs, "blk-3");
3615 assert_eq!(preview, "");
3616 assert_eq!(count, 0);
3617 }
3618
3619 #[test]
3620 fn read_session_preview_handles_no_user_messages() {
3621 let fs = fresh_filestore();
3622 let snapshot = serde_json::json!({
3623 "schemaVersion": 1,
3624 "nodes": [
3625 { "type": "markdown", "id": "m0", "content": "system note" }
3626 ]
3627 });
3628 write_snapshot(&fs, "blk-4", &snapshot.to_string());
3629 let (preview, count) = read_session_preview(&fs, "blk-4");
3630 assert_eq!(preview, "");
3631 assert_eq!(count, 1);
3632 }
3633
3634 use crate::backend::storage::store::{
3644 AgentDefinition, AgentInstance, Identity, InstanceStatus, Memory, Store,
3645 };
3646 use crate::backend::rpc::engine::WshRpcEngine;
3647 use crate::server::AppState;
3648 use std::sync::Arc;
3649
3650 async fn call_rpc<T: serde::de::DeserializeOwned>(
3653 engine: &Arc<WshRpcEngine>,
3654 rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
3655 command: &str,
3656 data: serde_json::Value,
3657 ) -> T {
3658 let req_id = format!("test-{}", uuid::Uuid::new_v4());
3659 let msg = crate::backend::rpc_types::RpcMessage {
3660 command: command.to_string(),
3661 reqid: req_id.clone(),
3662 data: Some(data),
3663 ..Default::default()
3664 };
3665 engine.handle_message(msg);
3666 let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
3667 .await
3668 .expect("handler timed out")
3669 .expect("output channel closed");
3670 assert_eq!(resp.resid, req_id, "unexpected response id");
3671 assert!(resp.error.is_empty(), "handler returned error: {}", resp.error);
3672 let payload = resp.data.unwrap_or(serde_json::Value::Null);
3673 serde_json::from_value(payload).expect("response deserialize")
3674 }
3675
3676 fn build_state_with_seed() -> (
3677 AppState,
3678 Arc<WshRpcEngine>,
3679 tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
3680 ) {
3681 let wstore = Arc::new(Store::open_in_memory().unwrap());
3682 let filestore = Arc::new(FileStore::open_in_memory().unwrap());
3683 let event_bus = Arc::new(crate::backend::eventbus::EventBus::new());
3684 let broker = Arc::new(crate::backend::wps::Broker::new());
3685 let reactive_handler = crate::backend::reactive::get_global_handler();
3686 let poller = Arc::new(crate::backend::reactive::Poller::new(
3687 crate::backend::reactive::PollerConfig {
3688 agentmux_url: None,
3689 agentmux_token: None,
3690 poll_interval_secs: 30,
3691 },
3692 reactive_handler,
3693 ));
3694 crate::backend::wcore::ensure_initial_data(&wstore).unwrap();
3695 let config_watcher = Arc::new(crate::backend::wconfig::ConfigWatcher::new());
3696 let process_tracker = Arc::new(
3697 crate::backend::process_tracker::registry::AgentProcessRegistry::new(Some(broker.clone())),
3698 );
3699 let state = AppState {
3700 auth_key: "test".to_string(),
3701 version: "test".to_string(),
3702 app_path: String::new(),
3703 wstore: wstore.clone(),
3704 filestore: filestore.clone(),
3705 global_transcript_store: None,
3706 event_bus: event_bus.clone(),
3707 broker,
3708 reactive_handler,
3709 poller,
3710 config_watcher,
3711 messagebus: Arc::new(crate::backend::messagebus::MessageBus::new()),
3712 http_client: reqwest::Client::new(),
3713 local_web_url: String::new(),
3714 subagent_watcher: Arc::new(crate::backend::subagent_watcher::SubagentWatcher::new(event_bus.clone())),
3715 history_service: Arc::new(crate::backend::history::HistoryService::new()),
3716 lan_discovery: Arc::new(crate::backend::lan_discovery::LanDiscoveryController::new(
3717 "test-instance".to_string(),
3718 "test-host".to_string(),
3719 "0.28.20".to_string(),
3720 0,
3721 event_bus.clone(),
3722 String::new(),
3723 )),
3724 lsp_supervisor: Arc::new(crate::backend::lsp::LspSupervisor::new(event_bus.clone())),
3725 process_tracker,
3726 srv_state: Arc::new(tokio::sync::Mutex::new(crate::state::State::default())),
3727 srv_events_tx: tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(64).0,
3728 saga_id_alloc: Arc::new(std::sync::atomic::AtomicU64::new(0)),
3729 saga_log: Arc::new(crate::sagas::log::SagaLog::open_in_memory().unwrap()),
3730 auth_session_manager: Arc::new(crate::identity::auth_session::AuthSessionManager::new()),
3731 install_sessions: crate::server::install_handlers::InstallSessionRegistry::new(),
3732 container_manager: None,
3733 shell_sessions: crate::backend::shell_node::ShellSessionRegistry::new(),
3734 };
3735
3736 let def = AgentDefinition {
3749 id: "def-claude".to_string(),
3750 slug: "claude-code".to_string(),
3751 name: "Claude Code".to_string(),
3752 icon: String::new(),
3753 provider: "claude".to_string(),
3754 description: String::new(),
3755 working_directory: String::new(),
3756 shell: String::new(),
3757 provider_flags: String::new(),
3758 auto_start: 0,
3759 restart_on_crash: 0,
3760 idle_timeout_minutes: 0,
3761 created_at: 0,
3762 agent_type: "host".to_string(),
3763 environment: String::new(),
3764 agent_bus_id: String::new(),
3765 is_seeded: 1,
3766 accounts: String::new(),
3767 parent_id: String::new(),
3768 branch_label: String::new(),
3769 updated_at: 0,
3770 user_hidden: 0,
3771 container_image: String::new(),
3772 container_volumes: "[]".to_string(),
3773 container_name: String::new(),
3774 };
3775 let mut def_mut = def.clone();
3776 wstore.agent_def_insert(&mut def_mut).unwrap();
3777 let identity = Identity {
3778 id: "id-work".to_string(),
3779 name: "Work".to_string(),
3780 description: String::new(),
3781 is_blank: false,
3782 created_at: 0,
3783 updated_at: 0,
3784 };
3785 wstore.bundle_identity_upsert(&identity).unwrap();
3786 let memory = Memory {
3787 id: "mem-notes".to_string(),
3788 name: "Notes".to_string(),
3789 description: String::new(),
3790 is_blank: false,
3791 is_global: false,
3792 provider: String::new(),
3793 model: String::new(),
3794 instructions: String::new(),
3795 context_files: "[]".to_string(),
3796 mcp_servers: "[]".to_string(),
3797 skills: "[]".to_string(),
3798 sort_order: 0,
3799 created_at: 0,
3800 updated_at: 0,
3801 };
3802 wstore.bundle_memory_upsert(&memory).unwrap();
3803
3804 for (id, block, started) in [
3811 ("inst-recent", "blk-recent", 1_700_000_100_000_i64),
3812 ("inst-older", "blk-older", 1_700_000_000_000_i64),
3813 ("inst-none", "blk-none", 1_700_000_050_000_i64),
3814 ] {
3815 let inst = AgentInstance {
3816 id: id.to_string(),
3817 definition_id: "def-claude".to_string(),
3818 parent_instance_id: String::new(),
3819 block_id: block.to_string(),
3820 session_id: String::new(),
3821 status: InstanceStatus::Running.as_str().to_string(),
3822 github_context: String::new(),
3823 started_at: started,
3824 ended_at: 0,
3825 created_at: started,
3826 identity_id: "id-work".to_string(),
3827 memory_id: "mem-notes".to_string(),
3828 instance_name: format!("name-{id}"),
3829 working_directory: format!("/tmp/{id}"),
3830 display_hidden: false,
3831 };
3832 wstore.instance_create(&inst).unwrap();
3833 }
3834
3835 let snap_older = serde_json::json!({
3846 "schemaVersion": 1,
3847 "nodes": [
3848 {"type": "user_message", "id": "u0",
3849 "message": "earlier conversation"}
3850 ]
3851 });
3852 write_snapshot(&filestore, "blk-older", &snap_older.to_string());
3853 let snap_recent = serde_json::json!({
3854 "schemaVersion": 1,
3855 "nodes": [
3856 {"type": "user_message", "id": "u0",
3857 "message": "# Session Context\nboilerplate"},
3858 {"type": "markdown", "id": "m0", "content": "ack"},
3859 {"type": "user_message", "id": "u1",
3860 "message": "fix the live-feed hover delay"}
3861 ]
3862 });
3863 write_snapshot(&filestore, "blk-recent", &snap_recent.to_string());
3864
3865 let (engine, rx) = WshRpcEngine::new();
3866 super::register_agent_handlers(&engine, &state);
3867 (state, engine, rx)
3868 }
3869
3870 #[tokio::test]
3871 async fn handler_returns_sessions_with_previews_sorted_by_snapshot_first() {
3872 let (_state, engine, mut rx) = build_state_with_seed();
3873 let rows: Vec<RecentSessionRow> = call_rpc(
3874 &engine,
3875 &mut rx,
3876 COMMAND_LIST_RECENT_SESSIONS,
3877 serde_json::json!({}),
3878 )
3879 .await;
3880 assert_eq!(rows.len(), 3, "all three sessions surfaced");
3881
3882 assert_eq!(rows[0].instance_id, "inst-recent");
3885 assert!(rows[0].has_snapshot);
3886 assert_eq!(rows[0].node_count, 3);
3887 assert!(
3888 rows[0].preview.starts_with("fix the live-feed"),
3889 "preview should be the post-context user message, got {:?}",
3890 rows[0].preview
3891 );
3892
3893 assert_eq!(rows[1].instance_id, "inst-older");
3894 assert!(rows[1].has_snapshot);
3895 assert_eq!(rows[1].node_count, 1);
3896 assert_eq!(rows[1].preview, "earlier conversation");
3897
3898 assert_eq!(rows[2].instance_id, "inst-none");
3899 assert!(!rows[2].has_snapshot);
3900 assert_eq!(rows[2].node_count, 0);
3901 assert_eq!(rows[2].preview, "");
3902
3903 assert_eq!(rows[0].definition_name, "Claude Code");
3905 assert_eq!(rows[0].identity_name, "Work");
3906 assert_eq!(rows[0].memory_name, "Notes");
3907 assert_eq!(rows[0].block_id_hint, "blk-recent");
3908 }
3909
3910 #[tokio::test]
3911 async fn handler_identity_filter_restricts_rows() {
3912 let (_state, engine, mut rx) = build_state_with_seed();
3913 let rows: Vec<RecentSessionRow> = call_rpc(
3915 &engine,
3916 &mut rx,
3917 COMMAND_LIST_RECENT_SESSIONS,
3918 serde_json::json!({ "identity_id": "no-such-bundle" }),
3919 )
3920 .await;
3921 assert_eq!(rows.len(), 0);
3922
3923 let rows: Vec<RecentSessionRow> = call_rpc(
3925 &engine,
3926 &mut rx,
3927 COMMAND_LIST_RECENT_SESSIONS,
3928 serde_json::json!({ "identity_id": "id-work" }),
3929 )
3930 .await;
3931 assert_eq!(rows.len(), 3);
3932
3933 let rows: Vec<RecentSessionRow> = call_rpc(
3936 &engine,
3937 &mut rx,
3938 COMMAND_LIST_RECENT_SESSIONS,
3939 serde_json::json!({ "identity_id": "" }),
3940 )
3941 .await;
3942 assert_eq!(rows.len(), 3);
3943 }
3944
3945 #[tokio::test]
3946 async fn handler_respects_limit() {
3947 let (_state, engine, mut rx) = build_state_with_seed();
3948 let rows: Vec<RecentSessionRow> = call_rpc(
3949 &engine,
3950 &mut rx,
3951 COMMAND_LIST_RECENT_SESSIONS,
3952 serde_json::json!({ "limit": 1 }),
3953 )
3954 .await;
3955 assert_eq!(rows.len(), 1);
3956 assert_eq!(rows[0].instance_id, "inst-recent");
3957 }
3958
3959 fn build_state_with_template_seed() -> (
3967 AppState,
3968 Arc<WshRpcEngine>,
3969 tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
3970 ) {
3971 let wstore = Arc::new(Store::open_in_memory().unwrap());
3972 let filestore = Arc::new(FileStore::open_in_memory().unwrap());
3973 let event_bus = Arc::new(crate::backend::eventbus::EventBus::new());
3974 let broker = Arc::new(crate::backend::wps::Broker::new());
3975 let reactive_handler = crate::backend::reactive::get_global_handler();
3976 let poller = Arc::new(crate::backend::reactive::Poller::new(
3977 crate::backend::reactive::PollerConfig {
3978 agentmux_url: None,
3979 agentmux_token: None,
3980 poll_interval_secs: 30,
3981 },
3982 reactive_handler,
3983 ));
3984 crate::backend::wcore::ensure_initial_data(&wstore).unwrap();
3985 let config_watcher = Arc::new(crate::backend::wconfig::ConfigWatcher::new());
3986 let process_tracker = Arc::new(
3987 crate::backend::process_tracker::registry::AgentProcessRegistry::new(Some(broker.clone())),
3988 );
3989 let state = AppState {
3990 auth_key: "test".to_string(),
3991 version: "test".to_string(),
3992 app_path: String::new(),
3993 wstore: wstore.clone(),
3994 filestore: filestore.clone(),
3995 global_transcript_store: None,
3996 event_bus: event_bus.clone(),
3997 broker,
3998 reactive_handler,
3999 poller,
4000 config_watcher,
4001 messagebus: Arc::new(crate::backend::messagebus::MessageBus::new()),
4002 http_client: reqwest::Client::new(),
4003 local_web_url: String::new(),
4004 subagent_watcher: Arc::new(crate::backend::subagent_watcher::SubagentWatcher::new(event_bus.clone())),
4005 history_service: Arc::new(crate::backend::history::HistoryService::new()),
4006 lan_discovery: Arc::new(crate::backend::lan_discovery::LanDiscoveryController::new(
4007 "test-instance".to_string(),
4008 "test-host".to_string(),
4009 "0.28.20".to_string(),
4010 0,
4011 event_bus.clone(),
4012 String::new(),
4013 )),
4014 lsp_supervisor: Arc::new(crate::backend::lsp::LspSupervisor::new(event_bus.clone())),
4015 process_tracker,
4016 srv_state: Arc::new(tokio::sync::Mutex::new(crate::state::State::default())),
4017 srv_events_tx: tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(64).0,
4018 saga_id_alloc: Arc::new(std::sync::atomic::AtomicU64::new(0)),
4019 saga_log: Arc::new(crate::sagas::log::SagaLog::open_in_memory().unwrap()),
4020 auth_session_manager: Arc::new(crate::identity::auth_session::AuthSessionManager::new()),
4021 install_sessions: crate::server::install_handlers::InstallSessionRegistry::new(),
4022 container_manager: None,
4023 shell_sessions: crate::backend::shell_node::ShellSessionRegistry::new(),
4024 };
4025
4026 let mut tpl = AgentDefinition {
4028 id: "tpl-claude".to_string(),
4029 slug: String::new(),
4030 name: "Claude Code".to_string(),
4031 icon: String::new(),
4032 provider: "claude".to_string(),
4033 description: "Anthropic's coding agent".to_string(),
4034 working_directory: String::new(),
4035 shell: String::new(),
4036 provider_flags: "--model haiku".to_string(),
4037 auto_start: 0,
4038 restart_on_crash: 0,
4039 idle_timeout_minutes: 0,
4040 created_at: 1_700_000_000_000,
4041 agent_type: "host".to_string(),
4042 environment: String::new(),
4043 agent_bus_id: String::new(),
4044 is_seeded: 1,
4045 accounts: String::new(),
4046 parent_id: String::new(),
4047 branch_label: String::new(),
4048 updated_at: 1_700_000_000_000,
4049 user_hidden: 0,
4050 container_image: String::new(),
4051 container_volumes: "[]".to_string(),
4052 container_name: String::new(),
4053 };
4054 wstore.agent_def_insert(&mut tpl).unwrap();
4055
4056 let mut user_a = AgentDefinition {
4057 id: "user-a".to_string(),
4058 slug: String::new(),
4059 name: "Maks".to_string(),
4060 icon: String::new(),
4061 provider: "claude".to_string(),
4062 description: String::new(),
4063 working_directory: String::new(),
4064 shell: String::new(),
4065 provider_flags: String::new(),
4066 auto_start: 0,
4067 restart_on_crash: 0,
4068 idle_timeout_minutes: 0,
4069 created_at: 1_700_000_001_000,
4070 agent_type: "host".to_string(),
4071 environment: String::new(),
4072 agent_bus_id: String::new(),
4073 is_seeded: 0,
4074 accounts: String::new(),
4075 parent_id: String::new(),
4076 branch_label: String::new(),
4077 updated_at: 1_700_000_001_000,
4078 user_hidden: 0,
4079 container_image: String::new(),
4080 container_volumes: "[]".to_string(),
4081 container_name: String::new(),
4082 };
4083 wstore.agent_def_insert(&mut user_a).unwrap();
4084
4085 let (engine, rx) = WshRpcEngine::new();
4086 super::register_agent_handlers(&engine, &state);
4087 (state, engine, rx)
4088 }
4089
4090 #[tokio::test]
4091 async fn listagents_no_filter_returns_all() {
4092 let (_state, engine, mut rx) = build_state_with_template_seed();
4093 let agents: Vec<AgentDefinition> = call_rpc(
4094 &engine,
4095 &mut rx,
4096 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4097 serde_json::json!({}),
4098 )
4099 .await;
4100 assert!(agents.iter().any(|a| a.id == "tpl-claude"));
4101 assert!(agents.iter().any(|a| a.id == "user-a"));
4102 }
4103
4104 #[tokio::test]
4105 async fn listagents_filter_templates_only() {
4106 let (_state, engine, mut rx) = build_state_with_template_seed();
4107 let agents: Vec<AgentDefinition> = call_rpc(
4108 &engine,
4109 &mut rx,
4110 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4111 serde_json::json!({ "is_seeded": 1 }),
4112 )
4113 .await;
4114 assert!(agents.iter().all(|a| a.is_seeded == 1));
4115 assert!(agents.iter().any(|a| a.id == "tpl-claude"));
4116 assert!(!agents.iter().any(|a| a.id == "user-a"));
4117 }
4118
4119 #[tokio::test]
4120 async fn listagents_filter_user_owned_only() {
4121 let (_state, engine, mut rx) = build_state_with_template_seed();
4122 let agents: Vec<AgentDefinition> = call_rpc(
4123 &engine,
4124 &mut rx,
4125 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4126 serde_json::json!({ "is_seeded": 0 }),
4127 )
4128 .await;
4129 assert!(agents.iter().all(|a| a.is_seeded == 0));
4130 assert!(agents.iter().any(|a| a.id == "user-a"));
4131 assert!(!agents.iter().any(|a| a.id == "tpl-claude"));
4132 }
4133
4134 #[tokio::test]
4135 async fn create_from_template_happy_path_clones_and_returns_id() {
4136 let (_state, engine, mut rx) = build_state_with_template_seed();
4137 let resp: crate::backend::rpc_types::AgentDefCreateFromTemplateResult = call_rpc(
4138 &engine,
4139 &mut rx,
4140 crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
4141 serde_json::json!({
4142 "template_id": "tpl-claude",
4143 "name": "Asaf",
4144 "identity_id": "id-work",
4145 "memory_id": "mem-notes",
4146 }),
4147 )
4148 .await;
4149 assert!(!resp.definition_id.is_empty());
4150 assert_eq!(resp.identity_id, "id-work");
4151 assert_eq!(resp.memory_id, "mem-notes");
4152
4153 let agents: Vec<AgentDefinition> = call_rpc(
4155 &engine,
4156 &mut rx,
4157 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4158 serde_json::json!({}),
4159 )
4160 .await;
4161 let new_def = agents
4162 .iter()
4163 .find(|a| a.id == resp.definition_id)
4164 .expect("new definition should appear in listagents");
4165 assert_eq!(new_def.is_seeded, 0);
4166 assert_eq!(new_def.name, "Asaf");
4167 assert_eq!(new_def.provider, "claude");
4168 assert_eq!(new_def.provider_flags, "--model haiku");
4169 assert_eq!(new_def.parent_id, "tpl-claude");
4170 }
4171
4172 async fn call_rpc_expect_error(
4173 engine: &Arc<WshRpcEngine>,
4174 rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
4175 command: &str,
4176 data: serde_json::Value,
4177 ) -> String {
4178 let req_id = format!("test-{}", uuid::Uuid::new_v4());
4179 let msg = crate::backend::rpc_types::RpcMessage {
4180 command: command.to_string(),
4181 reqid: req_id.clone(),
4182 data: Some(data),
4183 ..Default::default()
4184 };
4185 engine.handle_message(msg);
4186 let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
4187 .await
4188 .expect("handler timed out")
4189 .expect("output channel closed");
4190 assert_eq!(resp.resid, req_id);
4191 assert!(
4192 !resp.error.is_empty(),
4193 "expected error, got success payload: {:?}",
4194 resp.data
4195 );
4196 resp.error
4197 }
4198
4199 #[tokio::test]
4200 async fn create_from_template_rejects_non_template_id() {
4201 let (_state, engine, mut rx) = build_state_with_template_seed();
4202 let err = call_rpc_expect_error(
4204 &engine,
4205 &mut rx,
4206 crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
4207 serde_json::json!({
4208 "template_id": "user-a",
4209 "name": "another",
4210 }),
4211 )
4212 .await;
4213 assert!(
4214 err.contains("not a seeded template"),
4215 "wrong error: {err}"
4216 );
4217 }
4218
4219 #[tokio::test]
4220 async fn create_from_template_rejects_unknown_template_id() {
4221 let (_state, engine, mut rx) = build_state_with_template_seed();
4222 let err = call_rpc_expect_error(
4223 &engine,
4224 &mut rx,
4225 crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
4226 serde_json::json!({
4227 "template_id": "no-such-id",
4228 "name": "x",
4229 }),
4230 )
4231 .await;
4232 assert!(err.contains("not found"), "wrong error: {err}");
4233 }
4234
4235 #[tokio::test]
4236 async fn create_from_template_rejects_duplicate_user_name() {
4237 let (_state, engine, mut rx) = build_state_with_template_seed();
4238 let err = call_rpc_expect_error(
4240 &engine,
4241 &mut rx,
4242 crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
4243 serde_json::json!({
4244 "template_id": "tpl-claude",
4245 "name": "Maks",
4246 }),
4247 )
4248 .await;
4249 assert!(
4250 err.contains("already exists"),
4251 "wrong error: {err}"
4252 );
4253 }
4254
4255 #[tokio::test]
4256 async fn create_from_template_rejects_empty_name() {
4257 let (_state, engine, mut rx) = build_state_with_template_seed();
4258 let err = call_rpc_expect_error(
4259 &engine,
4260 &mut rx,
4261 crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
4262 serde_json::json!({
4263 "template_id": "tpl-claude",
4264 "name": " ",
4265 }),
4266 )
4267 .await;
4268 assert!(err.contains("non-empty"), "wrong error: {err}");
4269 }
4270
4271 #[tokio::test]
4276 async fn hide_template_then_listagents_excludes_it_by_default() {
4277 let (_state, engine, mut rx) = build_state_with_template_seed();
4278
4279 let before: Vec<AgentDefinition> = call_rpc(
4281 &engine,
4282 &mut rx,
4283 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4284 serde_json::json!({}),
4285 )
4286 .await;
4287 assert!(before.iter().any(|a| a.id == "tpl-claude"));
4288
4289 let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
4291 &engine,
4292 &mut rx,
4293 crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
4294 serde_json::json!({ "definition_id": "tpl-claude" }),
4295 )
4296 .await;
4297 assert!(resp.ok);
4298
4299 let after: Vec<AgentDefinition> = call_rpc(
4301 &engine,
4302 &mut rx,
4303 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4304 serde_json::json!({}),
4305 )
4306 .await;
4307 assert!(
4308 !after.iter().any(|a| a.id == "tpl-claude"),
4309 "hidden template should NOT appear by default",
4310 );
4311
4312 assert!(after.iter().any(|a| a.id == "user-a"));
4315
4316 let included: Vec<AgentDefinition> = call_rpc(
4318 &engine,
4319 &mut rx,
4320 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4321 serde_json::json!({ "include_hidden": true }),
4322 )
4323 .await;
4324 let tpl = included
4325 .iter()
4326 .find(|a| a.id == "tpl-claude")
4327 .expect("hidden template should appear with include_hidden=true");
4328 assert_eq!(tpl.user_hidden, 1);
4329 }
4330
4331 #[tokio::test]
4332 async fn hide_then_unhide_round_trip() {
4333 let (_state, engine, mut rx) = build_state_with_template_seed();
4334 let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
4335 &engine,
4336 &mut rx,
4337 crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
4338 serde_json::json!({ "definition_id": "tpl-claude" }),
4339 )
4340 .await;
4341 let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
4342 &engine,
4343 &mut rx,
4344 crate::backend::rpc_types::COMMAND_AGENT_DEF_UNHIDE,
4345 serde_json::json!({ "definition_id": "tpl-claude" }),
4346 )
4347 .await;
4348 assert!(resp.ok);
4349 let agents: Vec<AgentDefinition> = call_rpc(
4351 &engine,
4352 &mut rx,
4353 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4354 serde_json::json!({}),
4355 )
4356 .await;
4357 let tpl = agents
4358 .iter()
4359 .find(|a| a.id == "tpl-claude")
4360 .expect("unhidden template should appear");
4361 assert_eq!(tpl.user_hidden, 0);
4362 }
4363
4364 #[tokio::test]
4365 async fn hide_rejects_user_owned_definition() {
4366 let (_state, engine, mut rx) = build_state_with_template_seed();
4367 let err = call_rpc_expect_error(
4369 &engine,
4370 &mut rx,
4371 crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
4372 serde_json::json!({ "definition_id": "user-a" }),
4373 )
4374 .await;
4375 assert!(
4376 err.contains("not a seeded template"),
4377 "wrong error: {err}"
4378 );
4379 }
4380
4381 #[tokio::test]
4382 async fn hide_unknown_id_returns_ok_false() {
4383 let (_state, engine, mut rx) = build_state_with_template_seed();
4384 let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
4385 &engine,
4386 &mut rx,
4387 crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
4388 serde_json::json!({ "definition_id": "no-such-id" }),
4389 )
4390 .await;
4391 assert!(!resp.ok);
4392 }
4393
4394 #[tokio::test]
4395 async fn list_hidden_templates_returns_only_hidden_templates() {
4396 let (_state, engine, mut rx) = build_state_with_template_seed();
4397 let empty: Vec<AgentDefinition> = call_rpc(
4399 &engine,
4400 &mut rx,
4401 crate::backend::rpc_types::COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
4402 serde_json::json!({}),
4403 )
4404 .await;
4405 assert!(empty.is_empty());
4406
4407 let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
4409 &engine,
4410 &mut rx,
4411 crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
4412 serde_json::json!({ "definition_id": "tpl-claude" }),
4413 )
4414 .await;
4415 let hidden: Vec<AgentDefinition> = call_rpc(
4416 &engine,
4417 &mut rx,
4418 crate::backend::rpc_types::COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
4419 serde_json::json!({}),
4420 )
4421 .await;
4422 assert_eq!(hidden.len(), 1);
4423 assert_eq!(hidden[0].id, "tpl-claude");
4424 assert_eq!(hidden[0].is_seeded, 1);
4425 assert_eq!(hidden[0].user_hidden, 1);
4426 }
4427
4428 #[tokio::test]
4429 async fn listagents_is_seeded_filter_with_include_hidden_combines() {
4430 let (_state, engine, mut rx) = build_state_with_template_seed();
4434 let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
4435 &engine,
4436 &mut rx,
4437 crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
4438 serde_json::json!({ "definition_id": "tpl-claude" }),
4439 )
4440 .await;
4441 let templates_visible: Vec<AgentDefinition> = call_rpc(
4442 &engine,
4443 &mut rx,
4444 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4445 serde_json::json!({ "is_seeded": 1 }),
4446 )
4447 .await;
4448 assert!(
4449 !templates_visible.iter().any(|a| a.id == "tpl-claude"),
4450 "hidden template should be excluded from is_seeded=1 default query",
4451 );
4452 let templates_all: Vec<AgentDefinition> = call_rpc(
4453 &engine,
4454 &mut rx,
4455 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4456 serde_json::json!({ "is_seeded": 1, "include_hidden": true }),
4457 )
4458 .await;
4459 assert!(templates_all.iter().any(|a| a.id == "tpl-claude"));
4460 }
4461}