Skip to main content

agentmux_srv/server/agent_handlers/
core.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4
5use std::sync::Arc;
6use chrono::Utc;
7
8use crate::backend::rpc::engine::WshRpcEngine;
9use crate::backend::rpc_types::{
10    COMMAND_LIST_AGENTS, COMMAND_CREATE_AGENT, COMMAND_UPDATE_AGENT,
11    COMMAND_DELETE_AGENT, COMMAND_GET_AGENT_CONTENT, COMMAND_SET_AGENT_CONTENT,
12    COMMAND_GET_ALL_AGENT_CONTENT,
13    COMMAND_IMPORT_AGENT_FROM_CLAW, COMMAND_IMPORT_AGENTS, COMMAND_EXPORT_AGENTS,
14    COMMAND_RESEED_AGENTS,
15    COMMAND_CONTAINER_RUNTIME_AVAILABLE, COMMAND_CONTAINER_IMAGE_CHECK,
16    CommandContainerImageCheckData, ContainerImageCheckResult,
17    CommandListAgentDefinitionsData,
18    CommandContainerRuntimeAvailableData, CommandReseedAgentsData, CommandExportAgentsData,
19    ContainerRuntimeAvailableResult, ReseedAgentsResult,
20    CommandCreateAgentDefinitionData, CommandUpdateAgentDefinitionData, CommandDeleteAgentDefinitionData,
21    CommandGetAgentContentData, CommandSetAgentContentData, CommandGetAllAgentContentData,
22    CommandImportAgentFromClawData,
23    CommandImportAgentDefinitionsData, ImportAgentDefinitionsResult,
24    ExportAgentDefinitionsResult, AgentDefinitionExport, AgentSkillExport,
25};
26use crate::backend::storage::{AgentDefinition, AgentContent, AgentSkill};
27
28use super::super::AppState;
29
30pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
31    // listagents → return all agent definitions, optionally filtered by
32    // `is_seeded`. Filter input is backward-compatible: callers that
33    // pass `null` / `{}` (every existing caller) get the full list.
34    // The two-tier picker (Phase 1) passes `{ is_seeded: 0 }` for the
35    // "My Agents" section and `{ is_seeded: 1 }` for the "Templates"
36    // section — see SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md.
37    //
38    // Phase 2 (Q2 Decision Y — hide templates): templates with
39    // `user_hidden = 1` are filtered out by default. Callers that want
40    // them back (the settings panel's "Hidden templates" surface) pass
41    // `include_hidden: true`. Hide filter applies ONLY to templates —
42    // user-owned definitions are unaffected (their `user_hidden` is
43    // always 0 by backend invariant; `agent_def_set_hidden` rejects
44    // non-template ids).
45    let mstore_lfa = state.mstore.clone();
46    // NOT migrated to `register_typed`, deliberately. `register_typed`
47    // deserializes the payload strictly and answers Err on failure, but this
48    // command tolerates a null/malformed body on purpose: older clients never
49    // sent one, and the server defaults a missing `data` field to
50    // `Value::Null`, which cannot deserialize into a struct. Typing it would
51    // turn "older client asks for the unfiltered list" into an RPC error.
52    //
53    // Same rule as `bundle.validate` and `widget.health`: a handler that
54    // deliberately accepts loose input keeps its `Value` request. Its RESPONSE
55    // is still `Vec<AgentDefinition>`, which IS generated -- that is where the
56    // drift risk lived.
57    engine.register_typed(COMMAND_LIST_AGENTS, move |data: serde_json::Value, _ctx| {
58        let mstore = mstore_lfa.clone();
59        async move {
60                // unwrap_or_default — both `null` and `{}` deserialize
61                // to the default (no filter). Anything malformed falls
62                // back to no-filter rather than erroring; older clients
63                // never sent a body for this RPC and we can't know
64                // which JSON shape they're on.
65                let cmd: CommandListAgentDefinitionsData =
66                    serde_json::from_value(data).unwrap_or_default();
67                let agents = mstore.agent_def_list().map_err(|e| format!("listagents: {e}"))?;
68                let is_seeded_filter = cmd.is_seeded;
69                let include_hidden = cmd.include_hidden;
70                let filtered: Vec<_> = agents
71                    .into_iter()
72                    .filter(|a| match is_seeded_filter {
73                        Some(flag) => a.is_seeded == flag,
74                        None => true,
75                    })
76                    // Default behaviour: drop hidden templates. The
77                    // settings panel opts back in with include_hidden.
78                    // User-owned rows (is_seeded == 0) are never
79                    // hideable; the conditional below is a no-op for
80                    // them.
81                    .filter(|a| {
82                        include_hidden || a.is_seeded != 1 || a.user_hidden == 0
83                    })
84                    .collect();
85                Ok(filtered)
86        }
87    });
88
89    // createagent → insert new agent, broadcast agents:changed
90    let mstore_cfa = state.mstore.clone();
91    let id_store_cfa = state.id_store.clone();
92    let broker_cfa = state.broker.clone();
93    engine.register_typed(
94        COMMAND_CREATE_AGENT,
95        move |cmd: CommandCreateAgentDefinitionData, _ctx| {
96            let mstore = mstore_cfa.clone();
97            let id_store = id_store_cfa.clone();
98            let broker = broker_cfa.clone();
99            async move {
100                let now = agentmux_common::time::now_ms();
101                // slug is empty here — agent_def_insert auto-derives it
102                // from name AND collision-resolves AND mutates the
103                // struct so we serialize the resolved value back to
104                // the frontend (not "").
105                let mut agent = AgentDefinition {
106                    id: uuid::Uuid::new_v4().to_string(),
107                    slug: String::new(),
108                    name: cmd.name,
109                    icon: cmd.icon,
110                    provider: cmd.provider,
111                    description: cmd.description,
112                    working_directory: cmd.working_directory,
113                    shell: cmd.shell,
114                    provider_flags: cmd.provider_flags,
115                    auto_start: cmd.auto_start,
116                    restart_on_crash: cmd.restart_on_crash,
117                    idle_timeout_minutes: cmd.idle_timeout_minutes,
118                    created_at: now,
119                    agent_type: cmd.agent_type,
120                    environment: cmd.environment,
121                    agent_bus_id: cmd.agent_bus_id,
122                    is_seeded: 0,
123                    accounts: String::new(),
124                    parent_id: String::new(),
125                    branch_label: String::new(),
126                    updated_at: now,
127                    user_hidden: 0,
128                    container_image: String::new(),
129                    container_volumes: "[]".to_string(),
130                    container_name: String::new(),
131                    // Inert (see the field's doc); always 0.
132                    use_ambient_login: 0,
133                    // Not settable via this RPC yet — only `agent.define`
134                    // (the App-API/MCP path) can set a model vendor override.
135                    model_vendor_base_url: String::new(),
136                    // Opt-in required. Toggled from the Warden Supervisor
137                    // panel (not this RPC) once that ships.
138                    auto_continue_enabled: 0,
139                    memory_id: String::new(),
140                    conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
141                };
142                mstore.agent_def_insert(&mut agent).map_err(|e| format!("createagent: {e}"))?;
143                // Every agent gets its own dedicated ABF bundle
144                // (ARCHITECTURE_MANDATORY_ABF_RETHINK_2026_08_14.md §3.2).
145                // Best-effort, same posture as the color assignment below.
146                // Bundle goes into id_store (the effective identity/memory
147                // store), not mstore — see agent_def_provision_and_bind_
148                // bundle's own doc comment for why they must differ.
149                mstore.agent_def_provision_and_bind_bundle(&id_store, &mut agent, now);
150                // Assign the agent its display color at creation
151                // (SPEC_AGENT_COLOR_2026_08_08.md). Best-effort: a failure
152                // here shouldn't fail the create — agent.open assigns a
153                // color on first open as the fallback.
154                let _ = mstore.agent_content_set(&crate::backend::storage::store::AgentContent {
155                    agent_id: agent.id.clone(),
156                    content_type: "ui:color".to_string(),
157                    content: crate::backend::agent_color::pick_agent_color(&agent.id).to_string(),
158                    updated_at: now,
159                });
160                broker.publish_event::<crate::backend::mps_events::AgentsChanged>(&(), vec![], 0);
161                Ok(agent)
162            }
163        },
164    );
165
166    // updateagent → update existing agent, broadcast agents:changed
167    let mstore_ufa = state.mstore.clone();
168    let broker_ufa = state.broker.clone();
169    engine.register_typed(
170        COMMAND_UPDATE_AGENT,
171        move |cmd: CommandUpdateAgentDefinitionData, _ctx| {
172            let mstore = mstore_ufa.clone();
173            let broker = broker_ufa.clone();
174            async move {
175                // Fetch existing to preserve created_at
176                let existing = mstore.agent_def_list().map_err(|e| format!("updateagent: {e}"))?;
177                let old = existing.iter().find(|a| a.id == cmd.id)
178                    .ok_or_else(|| format!("updateagent: agent {} not found", cmd.id))?;
179                crate::server::app_api::check_provider_unchanged(&old.id, &old.provider, &cmd.provider)
180                    .map_err(|e| format!("updateagent: {e}"))?;
181                // Model vendor base URL: `None` (omitted) preserves the
182                // stored value; `Some(url)` overrides it (including
183                // `Some("")` to explicitly clear). Validated against the
184                // (possibly also-updated) `cmd.provider` — same check
185                // `agent.define` applies.
186                let chosen_model_vendor_base_url = match &cmd.model_vendor_base_url {
187                    Some(url) => {
188                        crate::server::app_api::validate_vendor_base_url(&cmd.provider, url)
189                            .map_err(|e| format!("updateagent: {e}"))?;
190                        url.clone()
191                    }
192                    None => old.model_vendor_base_url.clone(),
193                };
194                // slug is preserved from the existing row — it's
195                // immutable after creation. The update path never
196                // accepts a new slug from the client.
197                let mut agent = AgentDefinition {
198                    id: cmd.id,
199                    slug: old.slug.clone(),
200                    name: cmd.name,
201                    icon: cmd.icon,
202                    provider: cmd.provider,
203                    description: cmd.description,
204                    working_directory: cmd.working_directory,
205                    shell: cmd.shell,
206                    provider_flags: cmd.provider_flags,
207                    auto_start: cmd.auto_start,
208                    restart_on_crash: cmd.restart_on_crash,
209                    idle_timeout_minutes: cmd.idle_timeout_minutes,
210                    created_at: old.created_at,
211                    agent_type: cmd.agent_type,
212                    environment: cmd.environment,
213                    agent_bus_id: cmd.agent_bus_id,
214                    is_seeded: old.is_seeded,
215                    // Preserve existing accounts when the caller omits the field
216                    // (cmd.accounts defaults to "" via #[serde(default)]). Callers
217                    // that only update name/icon/etc. (AgentDefForm, AgentPicker rename)
218                    // don't carry accounts, so falling back to old.accounts prevents
219                    // silently wiping saved assignments.
220                    accounts: if cmd.accounts.is_empty() { old.accounts.clone() } else { cmd.accounts },
221                    // parent_id + branch_label describe provenance and
222                    // are immutable post-insert (forks are separate rows,
223                    // not in-place edits).
224                    parent_id: old.parent_id.clone(),
225                    branch_label: old.branch_label.clone(),
226                    // Placeholder — agent_def_update self-stamps the real
227                    // timestamp and writes it back into `agent` below, so
228                    // the response body carries the fresh value.
229                    updated_at: old.updated_at,
230                    // Preserve user_hidden — updateagent edits the
231                    // definition payload, not the per-user view-state
232                    // flag. Hide/unhide go through their dedicated RPCs
233                    // (`agentdefhide` / `agentdefunhide`). Phase 2 of
234                    // SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md.
235                    user_hidden: old.user_hidden,
236                    // Preserve existing container config when the caller omits the field
237                    // (cmd.container_image defaults to "" via #[serde(default)]). Callers
238                    // that only update name/icon/etc. (AgentDefForm) don't carry container
239                    // fields, so falling back to old values prevents silently wiping a
240                    // container agent's image and volumes — same guard as `accounts` above.
241                    container_image: if cmd.container_image.is_empty() { old.container_image.clone() } else { cmd.container_image },
242                    container_volumes: if cmd.container_volumes == "[]" { old.container_volumes.clone() } else { cmd.container_volumes },
243                    // container_name is server-managed; preserve the existing value.
244                    container_name: old.container_name.clone(),
245                    // Inert; carried over unchanged (see the field's doc).
246                    use_ambient_login: old.use_ambient_login,
247                    model_vendor_base_url: chosen_model_vendor_base_url,
248                    // Preserve the auto-continue opt-in when the caller omits
249                    // the field (Option — most callers only edit name/icon/
250                    // accounts). The Warden Supervisor panel sends Some(0|1)
251                    // to flip it.
252                    auto_continue_enabled: cmd.auto_continue_enabled.unwrap_or(old.auto_continue_enabled),
253                    // Immutable post-insert, same as slug/parent_id/branch_label
254                    // above — agent_def_update's SET clause doesn't even
255                    // touch this column, but the in-memory struct still
256                    // needs the real value: it's what gets serialized back
257                    // to the frontend as the "updated" agent.
258                    memory_id: old.memory_id.clone(),
259                    // Not yet exposed by this RPC's own command schema
260                    // (CommandUpdateAgentData has no conversation_visibility
261                    // field) — preserve the existing value, same as
262                    // memory_id just above, rather than silently resetting
263                    // a configured agent back to the default on an
264                    // unrelated name/icon/accounts edit.
265                    conversation_visibility: old.conversation_visibility.clone(),
266                };
267                let found = mstore.agent_def_update(&mut agent).map_err(|e| format!("updateagent: {e}"))?;
268                if !found {
269                    return Err(format!("updateagent: agent {} not found", agent.id));
270                }
271                broker.publish_event::<crate::backend::mps_events::AgentsChanged>(&(), vec![], 0);
272                Ok(agent)
273            }
274        },
275    );
276
277    // deleteagent → delete agent by id, broadcast agents:changed
278    let mstore_dfa = state.mstore.clone();
279    let broker_dfa = state.broker.clone();
280    let identity_store_dfa = state.identity_store.clone();
281    let id_store_dfa = state.id_store.clone();
282    engine.register_typed(
283        COMMAND_DELETE_AGENT,
284        move |cmd: CommandDeleteAgentDefinitionData, _ctx| {
285            let mstore = mstore_dfa.clone();
286            let broker = broker_dfa.clone();
287            let identity_store = identity_store_dfa.clone();
288            let id_store = id_store_dfa.clone();
289            async move {
290                mstore.agent_def_delete(&cmd.id).map_err(|e| format!("deleteagent: {e}"))?;
291                super::purge_identity_store_rows(&identity_store, &id_store, &cmd.id, "deleteagent");
292                broker.publish_event::<crate::backend::mps_events::AgentsChanged>(&(), vec![], 0);
293                Ok(())
294            }
295        },
296    );
297
298    // containerruntimeavailable → does the Docker DAEMON answer a ping
299    // right now? Returns `{ available: bool }`. The create-from-template
300    // modal (via the frontend's shared toolchain-capabilities store) uses
301    // this to gate/default the container runtime. Distinct from
302    // `resolvecli docker`, which only confirms the CLI binary is on PATH —
303    // that false-positives when Docker is installed but the daemon is
304    // stopped, steering users into a container agent that can't start
305    // (codex P1 on #1576). `ContainerRuntimeHandle::is_available()` retries
306    // the connection on demand, so a daemon that came up or went down
307    // since launch is reflected without an app restart.
308    let container_manager_cra = state.container_manager.clone();
309    engine.register_typed(
310        COMMAND_CONTAINER_RUNTIME_AVAILABLE,
311        move |_cmd: Option<CommandContainerRuntimeAvailableData>, _ctx| {
312            let cm = container_manager_cra.clone();
313            async move {
314                let available = cm.is_available().await;
315                Ok(ContainerRuntimeAvailableResult { available })
316            }
317        },
318    );
319
320    // containerimagecheck → can this image be had on this machine? Answers from
321    // the daemon's own cache first, then an anonymous registry check, so the
322    // create modal does not preselect a container that cannot start.
323    let container_manager_cic = state.container_manager.clone();
324    engine.register_typed(
325        COMMAND_CONTAINER_IMAGE_CHECK,
326        move |cmd: Option<CommandContainerImageCheckData>, _ctx| {
327            let cm = container_manager_cic.clone();
328            async move {
329                let image = cmd.unwrap_or_default().image;
330                let manager = cm.get().await;
331                let status = crate::backend::container_image::check_image_access(manager.as_ref(), &image).await;
332                Ok(ContainerImageCheckResult { status })
333            }
334        },
335    );
336
337    // getagentcontent → return a single content blob for an agent
338    let mstore_gfc = state.mstore.clone();
339    engine.register_typed(
340        COMMAND_GET_AGENT_CONTENT,
341        move |cmd: CommandGetAgentContentData, _ctx| {
342            let mstore = mstore_gfc.clone();
343            async move {
344                let content = mstore.agent_content_get(&cmd.agent_id, &cmd.content_type)
345                    .map_err(|e| format!("getagentcontent: {e}"))?;
346                // `Option<AgentContent>`, not a hand-serialized Value: the schema
347                // registry records the Resp type, so serializing here would have it
348                // record `Value` and leave this one endpoint outside the drift net.
349                // Serializes identically -- `None` is JSON null, matching the stub's
350                // `Promise<AgentContent | null>`.
351                Ok(content)
352            }
353        },
354    );
355
356    // setagentcontent → upsert a content blob, broadcast agentcontent:changed
357    let mstore_sfc = state.mstore.clone();
358    let broker_sfc = state.broker.clone();
359    engine.register_typed(
360        COMMAND_SET_AGENT_CONTENT,
361        move |cmd: CommandSetAgentContentData, _ctx| {
362            let mstore = mstore_sfc.clone();
363            let broker = broker_sfc.clone();
364            async move {
365                let now = agentmux_common::time::now_ms();
366                let content = AgentContent {
367                    agent_id: cmd.agent_id,
368                    content_type: cmd.content_type,
369                    content: cmd.content,
370                    updated_at: now,
371                };
372                mstore.agent_content_set(&content).map_err(|e| format!("setagentcontent: {e}"))?;
373                broker.publish_event::<crate::backend::mps_events::AgentContentChanged>(&(), vec![], 0);
374                Ok(content)
375            }
376        },
377    );
378
379    // getallagentcontent → return all content blobs for an agent
380    let mstore_gafc = state.mstore.clone();
381    engine.register_typed(
382        COMMAND_GET_ALL_AGENT_CONTENT,
383        move |cmd: CommandGetAllAgentContentData, _ctx| {
384            let mstore = mstore_gafc.clone();
385            async move {
386                let contents = mstore.agent_content_get_all(&cmd.agent_id)
387                    .map_err(|e| format!("getallagentcontent: {e}"))?;
388                Ok(contents)
389            }
390        },
391    );
392
393    // ── Agent Import handler ───────────────────────────────────────────────
394
395    // importagentfromclaw → read claw workspace, create agent + content
396    let mstore_ifc = state.mstore.clone();
397    let id_store_ifc = state.id_store.clone();
398    let broker_ifc = state.broker.clone();
399    engine.register_typed(
400        COMMAND_IMPORT_AGENT_FROM_CLAW,
401        move |cmd: CommandImportAgentFromClawData, _ctx| {
402            let mstore = mstore_ifc.clone();
403            let id_store = id_store_ifc.clone();
404            let broker = broker_ifc.clone();
405            async move {
406
407                let workspace_path = std::path::Path::new(&cmd.workspace_path);
408                if !workspace_path.exists() {
409                    return Err(format!("importagentfromclaw: path does not exist: {}", cmd.workspace_path));
410                }
411
412                let now = agentmux_common::time::now_ms();
413
414                // Detect provider from .claude/settings.json if present
415                let mut provider = "claude".to_string();
416                let settings_path = workspace_path.join(".claude").join("settings.json");
417                if settings_path.exists() {
418                    if let Ok(settings_str) = std::fs::read_to_string(&settings_path) {
419                        if let Ok(settings) = serde_json::from_str::<serde_json::Value>(&settings_str) {
420                            if let Some(p) = settings.get("provider").and_then(|v| v.as_str()) {
421                                provider = p.to_string();
422                            }
423                        }
424                    }
425                }
426
427                // Create the agent — slug is empty, agent_def_insert will
428                // auto-derive from agent_name and mutate the struct
429                // so the resolved slug is returned to the frontend.
430                let mut agent = AgentDefinition {
431                    id: uuid::Uuid::new_v4().to_string(),
432                    slug: String::new(),
433                    name: cmd.agent_name.clone(),
434                    icon: "\u{2726}".to_string(),
435                    provider,
436                    description: format!("Imported from {}", cmd.workspace_path),
437                    working_directory: cmd.workspace_path.clone(),
438                    shell: String::new(),
439                    provider_flags: String::new(),
440                    auto_start: 0,
441                    restart_on_crash: 0,
442                    idle_timeout_minutes: 0,
443                    created_at: now,
444                    agent_type: "standalone".to_string(),
445                    environment: String::new(),
446                    agent_bus_id: String::new(),
447                    is_seeded: 0,
448                    accounts: String::new(),
449                    parent_id: String::new(),
450                    branch_label: String::new(),
451                    updated_at: now,
452                    user_hidden: 0,
453                    container_image: String::new(),
454                    container_volumes: "[]".to_string(),
455                    container_name: String::new(),
456                    use_ambient_login: 0,
457                    model_vendor_base_url: String::new(),
458                    auto_continue_enabled: 0,
459                    memory_id: String::new(),
460                    conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
461                };
462                mstore.agent_def_insert(&mut agent).map_err(|e| format!("importagentfromclaw: {e}"))?;
463                mstore.agent_def_provision_and_bind_bundle(&id_store, &mut agent, now);
464
465                // Read CLAUDE.md → agentmd content
466                let claude_md_path = workspace_path.join("CLAUDE.md");
467                if claude_md_path.exists() {
468                    if let Ok(content) = std::fs::read_to_string(&claude_md_path) {
469                        let fc = AgentContent {
470                            agent_id: agent.id.clone(),
471                            content_type: "agentmd".to_string(),
472                            content,
473                            updated_at: now,
474                        };
475                        let _ = mstore.agent_content_set(&fc);
476                    }
477                }
478
479                // Read .mcp.json → mcp content
480                let mcp_path = workspace_path.join(".mcp.json");
481                if mcp_path.exists() {
482                    if let Ok(content) = std::fs::read_to_string(&mcp_path) {
483                        let fc = AgentContent {
484                            agent_id: agent.id.clone(),
485                            content_type: "mcp".to_string(),
486                            content,
487                            updated_at: now,
488                        };
489                        let _ = mstore.agent_content_set(&fc);
490                    }
491                }
492
493                broker.publish_event::<crate::backend::mps_events::AgentsChanged>(&(), vec![], 0);
494                Ok(agent)
495            }
496        },
497    );
498
499    // reseedagents → delete all seeded agents and re-run seed from manifest
500    let mstore_rsfa = state.mstore.clone();
501    let broker_rsfa = state.broker.clone();
502    engine.register_typed(
503        COMMAND_RESEED_AGENTS,
504        move |_cmd: Option<CommandReseedAgentsData>, _ctx| {
505            let mstore = mstore_rsfa.clone();
506            let broker = broker_rsfa.clone();
507            async move {
508                // Delete all previously seeded agents (cascade deletes content, skills, history)
509                let deleted = mstore.agent_def_delete_seeded()
510                    .map_err(|e| format!("reseedagents: delete seeded: {e}"))?;
511
512                // Re-run seed
513                let report = crate::backend::agent_seed::seed_agents(&mstore)
514                    .map_err(|e| format!("reseedagents: seed: {e}"))?;
515
516                broker.publish_event::<crate::backend::mps_events::AgentsChanged>(&(), vec![], 0);
517                Ok(ReseedAgentsResult {
518                    deleted,
519                    created: report.created,
520                    skipped: report.skipped,
521                })
522            }
523        },
524    );
525
526    // importagents — bulk import from JSON export format
527    let mstore_ifa = state.mstore.clone();
528    let id_store_ifa = state.id_store.clone();
529    let broker_ifa = state.broker.clone();
530    engine.register_typed(
531        COMMAND_IMPORT_AGENTS,
532        move |cmd: CommandImportAgentDefinitionsData, _ctx| {
533            let mstore = mstore_ifa.clone();
534            let id_store = id_store_ifa.clone();
535            let broker = broker_ifa.clone();
536            async move {
537
538                let now = agentmux_common::time::now_ms();
539
540                let mut imported: Vec<String> = Vec::new();
541                let mut skipped: Vec<String> = Vec::new();
542                let mut failed: Vec<String> = Vec::new();
543
544                for agent_import in cmd.agents {
545                    // Check for existing agent by slug (id field from export)
546                    let existing = mstore.agent_def_list()
547                        .unwrap_or_default()
548                        .into_iter()
549                        .any(|a| a.slug == agent_import.id);
550
551                    if existing {
552                        skipped.push(agent_import.name.clone());
553                        continue;
554                    }
555
556                    let mut agent = AgentDefinition {
557                        id: uuid::Uuid::new_v4().to_string(),
558                        slug: agent_import.id.clone(),
559                        name: agent_import.name.clone(),
560                        icon: agent_import.icon.clone(),
561                        provider: agent_import.provider.clone(),
562                        description: agent_import.description.clone(),
563                        working_directory: agent_import.working_directory.clone(),
564                        shell: agent_import.shell.clone(),
565                        provider_flags: String::new(),
566                        auto_start: 0,
567                        restart_on_crash: if agent_import.restart_on_crash { 1 } else { 0 },
568                        idle_timeout_minutes: 0,
569                        created_at: now,
570                        agent_type: agent_import.agent_type.clone(),
571                        environment: agent_import.environment.clone(),
572                        agent_bus_id: agent_import.agent_bus_id.clone(),
573                        is_seeded: 0,
574                        accounts: String::new(),
575                        parent_id: String::new(),
576                        branch_label: String::new(),
577                        updated_at: now,
578                        user_hidden: 0,
579                        container_image: String::new(),
580                        container_volumes: "[]".to_string(),
581                        container_name: String::new(),
582                        use_ambient_login: 0,
583                        model_vendor_base_url: String::new(),
584                        auto_continue_enabled: 0,
585                        memory_id: String::new(),
586                        conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
587                    };
588
589                    if let Err(e) = mstore.agent_def_insert(&mut agent) {
590                        failed.push(format!("{}: {e}", agent_import.name));
591                        continue;
592                    }
593                    mstore.agent_def_provision_and_bind_bundle(&id_store, &mut agent, now);
594
595                    // Insert content types
596                    let mut content_ok = true;
597                    for (content_type, content) in &agent_import.content {
598                        let fc = AgentContent {
599                            agent_id: agent.id.clone(),
600                            content_type: content_type.clone(),
601                            content: content.clone(),
602                            updated_at: now,
603                        };
604                        if let Err(e) = mstore.agent_content_set(&fc) {
605                            tracing::warn!("import: failed to set content for agent {}: {e}", agent.id);
606                            content_ok = false;
607                        }
608                    }
609
610                    // Insert skills
611                    let mut skills_ok = true;
612                    for skill_import in &agent_import.skills {
613                        let skill = AgentSkill {
614                            id: uuid::Uuid::new_v4().to_string(),
615                            agent_id: agent.id.clone(),
616                            name: skill_import.name.clone(),
617                            trigger: skill_import.trigger.clone(),
618                            skill_type: skill_import.skill_type.clone(),
619                            description: skill_import.description.clone(),
620                            content: skill_import.content.clone(),
621                            created_at: now,
622                        };
623                        if let Err(e) = mstore.agent_skill_insert(&skill) {
624                            tracing::warn!("import: failed to insert skill '{}' for agent {}: {e}", skill.name, agent.id);
625                            skills_ok = false;
626                        }
627                    }
628
629                    if content_ok && skills_ok {
630                        imported.push(agent_import.name.clone());
631                    } else {
632                        failed.push(agent_import.name.clone());
633                    }
634                }
635
636                broker.publish_event::<crate::backend::mps_events::AgentsChanged>(&(), vec![], 0);
637
638                let result = ImportAgentDefinitionsResult { imported, skipped, failed };
639                Ok(result)
640            }
641        },
642    );
643
644    // exportagents — export all agent definitions with content and skills
645    let mstore_efa = state.mstore.clone();
646    let id_store_efa = state.id_store.clone();
647    engine.register_typed(
648        COMMAND_EXPORT_AGENTS,
649        move |_cmd: Option<CommandExportAgentsData>, _ctx| {
650            let mstore = mstore_efa.clone();
651            let id_store = id_store_efa.clone();
652            async move {
653                let agents = mstore.agent_def_list()
654                    .map_err(|e| format!("exportagents: list: {e}"))?;
655
656                let mut agent_exports: Vec<AgentDefinitionExport> = Vec::new();
657
658                for agent in agents {
659                    let content_map = mstore.agent_content_get_all(&agent.id)
660                        .unwrap_or_default()
661                        .into_iter()
662                        .map(|fc| (fc.content_type, fc.content))
663                        .collect::<std::collections::HashMap<String, String>>();
664
665                    let skills = mstore.agent_skill_list(&agent.id)
666                        .unwrap_or_default()
667                        .into_iter()
668                        .map(|s| AgentSkillExport {
669                            name: s.name,
670                            trigger: s.trigger,
671                            skill_type: s.skill_type,
672                            description: s.description,
673                            content: s.content,
674                        })
675                        .collect::<Vec<_>>();
676
677                    // The provider the agent runs, from the shared resolver
678                    // (#2594; SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.1): `importagents` gives it to the new
679                    // agent, so the import runs the same CLI.
680                    let effective_provider = id_store.resolve_effective_provider_id(&agent);
681                    agent_exports.push(AgentDefinitionExport {
682                        id: agent.slug.clone(),
683                        name: agent.name,
684                        icon: agent.icon,
685                        description: agent.description,
686                        provider: effective_provider,
687                        shell: agent.shell,
688                        working_directory: agent.working_directory,
689                        agent_bus_id: agent.agent_bus_id,
690                        agent_type: agent.agent_type,
691                        environment: agent.environment,
692                        restart_on_crash: agent.restart_on_crash != 0,
693                        content: content_map,
694                        skills,
695                    });
696                }
697
698                let exported_at = Utc::now().to_rfc3339();
699
700                let result = ExportAgentDefinitionsResult {
701                    version: 4,
702                    exported_at,
703                    source: "agentmux-export".to_string(),
704                    agents: agent_exports,
705                };
706                Ok(result)
707            }
708        },
709    );
710
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use crate::backend::rpc_types::RpcMessage;
717    use crate::backend::storage::bundles::Bundle;
718    use crate::server::tests::test_state;
719
720    fn seed_bundle(state: &AppState, id: &str, provider: &str) {
721        let bundle = Bundle {
722            id: id.to_string(),
723            name: "Bundle".to_string(),
724            description: String::new(),
725            is_blank: false,
726            is_global: false,
727            provider: provider.to_string(),
728            model: String::new(),
729            instructions: String::new(),
730            instructions_by_provider: "{}".to_string(),
731            context_files: "[]".to_string(),
732            mcp_servers: "[]".to_string(),
733            skills: "[]".to_string(),
734            sort_order: 0,
735            created_at: 0,
736            updated_at: 0,
737            is_system: false,
738        };
739        state.id_store.bundle_upsert(&bundle).unwrap();
740    }
741
742    // The agent says "codex"; its bound bundle, from before bundles stopped
743    // carrying a harness, says "claude". The agent owns its harness
744    // (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.1), so the export
745    // carries "codex", which `importagents` gives the imported agent.
746    fn seed_drifted_agent(state: &AppState, def_id: &str, bundle_id: &str) {
747        let mut def = AgentDefinition {
748            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
749            id: def_id.to_string(),
750            slug: def_id.to_string(),
751            name: "Drifted Agent".to_string(),
752            icon: String::new(),
753            provider: "codex".to_string(),
754            description: String::new(),
755            working_directory: String::new(),
756            shell: String::new(),
757            provider_flags: String::new(),
758            auto_start: 0,
759            restart_on_crash: 0,
760            idle_timeout_minutes: 0,
761            created_at: 0,
762            agent_type: "host".to_string(),
763            environment: String::new(),
764            agent_bus_id: String::new(),
765            is_seeded: 0,
766            accounts: String::new(),
767            parent_id: String::new(),
768            branch_label: String::new(),
769            updated_at: 0,
770            user_hidden: 0,
771            container_image: String::new(),
772            container_volumes: "[]".to_string(),
773            container_name: String::new(),
774            use_ambient_login: 0,
775            auto_continue_enabled: 0,
776            model_vendor_base_url: String::new(),
777            memory_id: bundle_id.to_string(),
778        };
779        state.mstore.agent_def_insert(&mut def).unwrap();
780    }
781
782    #[tokio::test]
783    async fn exportagents_carries_the_agents_own_provider_not_its_bundles() {
784        let state = test_state();
785        seed_bundle(&state, "bundle-claude", "claude");
786        seed_drifted_agent(&state, "agent-drift", "bundle-claude");
787
788        let (engine, mut output_rx) = WshRpcEngine::new();
789        register(&engine, &state);
790
791        engine.handle_message(RpcMessage {
792            command: COMMAND_EXPORT_AGENTS.to_string(),
793            reqid: "req-1".to_string(),
794            data: Some(serde_json::json!({})),
795            ..Default::default()
796        });
797        let resp = tokio::time::timeout(std::time::Duration::from_secs(2), output_rx.recv())
798            .await
799            .unwrap()
800            .unwrap();
801        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
802        let result: ExportAgentDefinitionsResult =
803            serde_json::from_value(resp.data.expect("expected result data")).unwrap();
804
805        let exported = result
806            .agents
807            .iter()
808            .find(|a| a.id == "agent-drift")
809            .expect("exported agent should be present");
810        assert_eq!(
811            exported.provider, "codex",
812            "the agent owns its harness; the bundle's provider is a hint (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.1)"
813        );
814    }
815}