agentmux_srv\server/
agent_handlers.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use 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    // Two-tier picker — Phase 1 (SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md)
21    COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
22    CommandAgentDefCreateFromTemplateData, AgentDefCreateFromTemplateResult,
23    CommandListAgentDefinitionsData,
24    // Two-tier picker — Phase 2 (SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md
25    // Q2 Decision Y: hide templates).
26    COMMAND_AGENT_DEF_HIDE, COMMAND_AGENT_DEF_UNHIDE,
27    COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
28    CommandAgentDefHideData, AgentDefHideResult,
29    CommandCreateAgentDefinitionData, CommandUpdateAgentDefinitionData, CommandDeleteAgentDefinitionData,
30    CommandGetAgentContentData, CommandSetAgentContentData, CommandGetAllAgentContentData,
31    CommandListAgentSkillsData, CommandCreateAgentSkillData, CommandUpdateAgentSkillData,
32    CommandDeleteAgentSkillData,
33    CommandAppendAgentHistoryData, CommandListAgentHistoryData, CommandSearchAgentHistoryData,
34    CommandImportAgentFromClawData,
35    CommandImportAgentDefinitionsData, ImportAgentDefinitionsResult,
36    ExportAgentDefinitionsResult, AgentDefinitionExport, AgentSkillExport,
37    // v6 identity / instance / fork
38    COMMAND_LIST_IDENTITY_ACCOUNTS, COMMAND_GET_IDENTITY_ACCOUNT,
39    COMMAND_UPSERT_IDENTITY_ACCOUNT, COMMAND_DELETE_IDENTITY_ACCOUNT,
40    COMMAND_LINK_AGENT_IDENTITY, COMMAND_UNLINK_AGENT_IDENTITY,
41    COMMAND_LIST_AGENT_IDENTITIES,
42    COMMAND_LIST_AGENT_INSTANCES, COMMAND_GET_AGENT_INSTANCE,
43    COMMAND_CREATE_AGENT_INSTANCE, COMMAND_UPDATE_AGENT_INSTANCE,
44    COMMAND_DELETE_AGENT_INSTANCE,
45    COMMAND_LIST_NAMED_AGENTS, COMMAND_HIDE_NAMED_AGENT,
46    CommandListNamedAgentsData, CommandHideNamedAgentData,
47    NamedAgentRow,
48    COMMAND_LIST_RECENT_SESSIONS, CommandListRecentSessionsData,
49    RecentSessionRow,
50    // Option E (PR 1 of 2) — agent-anchored session zones.
51    COMMAND_AGENT_SESSION_READ, COMMAND_AGENT_SESSION_WRITE_STATE,
52    COMMAND_AGENT_SESSION_APPEND_OUTPUT, COMMAND_AGENT_SESSION_ARCHIVE,
53    COMMAND_AGENT_SESSION_LIST_ARCHIVES,
54    CommandAgentSessionReadData, AgentSessionReadResult,
55    CommandAgentSessionWriteStateData, AgentSessionWriteStateResult,
56    CommandAgentSessionAppendOutputData, AgentSessionAppendOutputResult,
57    CommandAgentSessionArchiveData, AgentSessionArchiveResult,
58    CommandAgentSessionListArchivesData, AgentArchiveRow,
59    COMMAND_FORK_AGENT_DEFINITION,
60    CommandListIdentityAccountsData, CommandGetIdentityAccountData,
61    CommandDeleteIdentityAccountData,
62    CommandLinkAgentIdentityData, CommandUnlinkAgentIdentityData,
63    CommandListAgentIdentitiesData,
64    CommandListAgentInstancesData, CommandGetAgentInstanceData,
65    CommandCreateAgentInstanceData, CommandUpdateAgentInstanceData,
66    CommandDeleteAgentInstanceData,
67    CommandForkAgentDefinitionData,
68    // v7 Identity bundles + Memory
69    COMMAND_LIST_IDENTITY_BUNDLES, COMMAND_GET_IDENTITY_BUNDLE,
70    COMMAND_UPSERT_IDENTITY_BUNDLE, COMMAND_DELETE_IDENTITY_BUNDLE,
71    COMMAND_BIND_IDENTITY_ACCOUNT, COMMAND_UNBIND_IDENTITY_ACCOUNT,
72    COMMAND_LIST_IDENTITY_BINDINGS,
73    COMMAND_LIST_MEMORIES, COMMAND_GET_MEMORY,
74    COMMAND_UPSERT_MEMORY, COMMAND_DELETE_MEMORY,
75    CommandGetIdentityBundleData, CommandDeleteIdentityBundleData,
76    CommandBindIdentityAccountData, CommandUnbindIdentityAccountData,
77    CommandListIdentityBindingsData,
78    CommandGetMemoryData, CommandDeleteMemoryData,
79};
80use crate::backend::storage::{AgentDefinition, AgentContent, AgentSkill};
81use crate::backend::storage::wstore::{
82    AgentInstance, Identity, IdentityAccount, InstanceStatus, Memory,
83};
84
85use super::AppState;
86
87pub fn register_agent_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
88    // listagents → return all agent definitions, optionally filtered by
89    // `is_seeded`. Filter input is backward-compatible: callers that
90    // pass `null` / `{}` (every existing caller) get the full list.
91    // The two-tier picker (Phase 1) passes `{ is_seeded: 0 }` for the
92    // "My Agents" section and `{ is_seeded: 1 }` for the "Templates"
93    // section — see SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md.
94    //
95    // Phase 2 (Q2 Decision Y — hide templates): templates with
96    // `user_hidden = 1` are filtered out by default. Callers that want
97    // them back (the settings panel's "Hidden templates" surface) pass
98    // `include_hidden: true`. Hide filter applies ONLY to templates —
99    // user-owned definitions are unaffected (their `user_hidden` is
100    // always 0 by backend invariant; `agent_def_set_hidden` rejects
101    // non-template ids).
102    let wstore_lfa = state.wstore.clone();
103    engine.register_handler(
104        COMMAND_LIST_AGENTS,
105        Box::new(move |data, _ctx| {
106            let wstore = wstore_lfa.clone();
107            Box::pin(async move {
108                // unwrap_or_default — both `null` and `{}` deserialize
109                // to the default (no filter). Anything malformed falls
110                // back to no-filter rather than erroring; older clients
111                // never sent a body for this RPC and we can't know
112                // which JSON shape they're on.
113                let cmd: CommandListAgentDefinitionsData =
114                    serde_json::from_value(data).unwrap_or_default();
115                let agents = wstore.agent_def_list().map_err(|e| format!("listagents: {e}"))?;
116                let is_seeded_filter = cmd.is_seeded;
117                let include_hidden = cmd.include_hidden;
118                let filtered: Vec<_> = agents
119                    .into_iter()
120                    .filter(|a| match is_seeded_filter {
121                        Some(flag) => a.is_seeded == flag,
122                        None => true,
123                    })
124                    // Default behaviour: drop hidden templates. The
125                    // settings panel opts back in with include_hidden.
126                    // User-owned rows (is_seeded == 0) are never
127                    // hideable; the conditional below is a no-op for
128                    // them.
129                    .filter(|a| {
130                        include_hidden || a.is_seeded != 1 || a.user_hidden == 0
131                    })
132                    .collect();
133                Ok(Some(serde_json::to_value(&filtered).unwrap_or_default()))
134            })
135        }),
136    );
137
138    // createagent → insert new agent, broadcast agents:changed
139    let wstore_cfa = state.wstore.clone();
140    let broker_cfa = state.broker.clone();
141    engine.register_handler(
142        COMMAND_CREATE_AGENT,
143        Box::new(move |data, _ctx| {
144            let wstore = wstore_cfa.clone();
145            let broker = broker_cfa.clone();
146            Box::pin(async move {
147                let cmd: CommandCreateAgentDefinitionData = serde_json::from_value(data)
148                    .map_err(|e| format!("createagent: {e}"))?;
149                let now = SystemTime::now()
150                    .duration_since(UNIX_EPOCH)
151                    .unwrap_or_default()
152                    .as_millis() as i64;
153                // slug is empty here — agent_def_insert auto-derives it
154                // from name AND collision-resolves AND mutates the
155                // struct so we serialize the resolved value back to
156                // the frontend (not "").
157                let mut agent = AgentDefinition {
158                    id: uuid::Uuid::new_v4().to_string(),
159                    slug: String::new(),
160                    name: cmd.name,
161                    icon: cmd.icon,
162                    provider: cmd.provider,
163                    description: cmd.description,
164                    working_directory: cmd.working_directory,
165                    shell: cmd.shell,
166                    provider_flags: cmd.provider_flags,
167                    auto_start: cmd.auto_start,
168                    restart_on_crash: cmd.restart_on_crash,
169                    idle_timeout_minutes: cmd.idle_timeout_minutes,
170                    created_at: now,
171                    agent_type: cmd.agent_type,
172                    environment: cmd.environment,
173                    agent_bus_id: cmd.agent_bus_id,
174                    is_seeded: 0,
175                    accounts: String::new(),
176                    parent_id: String::new(),
177                    branch_label: String::new(),
178                    updated_at: now,
179                    user_hidden: 0,
180                };
181                wstore.agent_def_insert(&mut agent).map_err(|e| format!("createagent: {e}"))?;
182                broker.publish(crate::backend::wps::WaveEvent {
183                    event: "agents:changed".to_string(),
184                    scopes: vec![],
185                    sender: String::new(),
186                    persist: 0,
187                    data: None,
188                });
189                Ok(Some(serde_json::to_value(&agent).unwrap_or_default()))
190            })
191        }),
192    );
193
194    // updateagent → update existing agent, broadcast agents:changed
195    let wstore_ufa = state.wstore.clone();
196    let broker_ufa = state.broker.clone();
197    engine.register_handler(
198        COMMAND_UPDATE_AGENT,
199        Box::new(move |data, _ctx| {
200            let wstore = wstore_ufa.clone();
201            let broker = broker_ufa.clone();
202            Box::pin(async move {
203                let cmd: CommandUpdateAgentDefinitionData = serde_json::from_value(data)
204                    .map_err(|e| format!("updateagent: {e}"))?;
205                // Fetch existing to preserve created_at
206                let existing = wstore.agent_def_list().map_err(|e| format!("updateagent: {e}"))?;
207                let old = existing.iter().find(|a| a.id == cmd.id)
208                    .ok_or_else(|| format!("updateagent: agent {} not found", cmd.id))?;
209                // slug is preserved from the existing row — it's
210                // immutable after creation. The update path never
211                // accepts a new slug from the client.
212                let mut agent = AgentDefinition {
213                    id: cmd.id,
214                    slug: old.slug.clone(),
215                    name: cmd.name,
216                    icon: cmd.icon,
217                    provider: cmd.provider,
218                    description: cmd.description,
219                    working_directory: cmd.working_directory,
220                    shell: cmd.shell,
221                    provider_flags: cmd.provider_flags,
222                    auto_start: cmd.auto_start,
223                    restart_on_crash: cmd.restart_on_crash,
224                    idle_timeout_minutes: cmd.idle_timeout_minutes,
225                    created_at: old.created_at,
226                    agent_type: cmd.agent_type,
227                    environment: cmd.environment,
228                    agent_bus_id: cmd.agent_bus_id,
229                    is_seeded: old.is_seeded,
230                    // Preserve existing accounts when the caller omits the field
231                    // (cmd.accounts defaults to "" via #[serde(default)]). Callers
232                    // that only update name/icon/etc. (AgentDefForm, AgentPicker rename)
233                    // don't carry accounts, so falling back to old.accounts prevents
234                    // silently wiping saved assignments.
235                    accounts: if cmd.accounts.is_empty() { old.accounts.clone() } else { cmd.accounts },
236                    // parent_id + branch_label describe provenance and
237                    // are immutable post-insert (forks are separate rows,
238                    // not in-place edits).
239                    parent_id: old.parent_id.clone(),
240                    branch_label: old.branch_label.clone(),
241                    // Placeholder — agent_def_update self-stamps the real
242                    // timestamp and writes it back into `agent` below, so
243                    // the response body carries the fresh value.
244                    updated_at: old.updated_at,
245                    // Preserve user_hidden — updateagent edits the
246                    // definition payload, not the per-user view-state
247                    // flag. Hide/unhide go through their dedicated RPCs
248                    // (`agentdefhide` / `agentdefunhide`). Phase 2 of
249                    // SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md.
250                    user_hidden: old.user_hidden,
251                };
252                let found = wstore.agent_def_update(&mut agent).map_err(|e| format!("updateagent: {e}"))?;
253                if !found {
254                    return Err(format!("updateagent: agent {} not found", agent.id));
255                }
256                broker.publish(crate::backend::wps::WaveEvent {
257                    event: "agents:changed".to_string(),
258                    scopes: vec![],
259                    sender: String::new(),
260                    persist: 0,
261                    data: None,
262                });
263                Ok(Some(serde_json::to_value(&agent).unwrap_or_default()))
264            })
265        }),
266    );
267
268    // deleteagent → delete agent by id, broadcast agents:changed
269    let wstore_dfa = state.wstore.clone();
270    let broker_dfa = state.broker.clone();
271    engine.register_handler(
272        COMMAND_DELETE_AGENT,
273        Box::new(move |data, _ctx| {
274            let wstore = wstore_dfa.clone();
275            let broker = broker_dfa.clone();
276            Box::pin(async move {
277                let cmd: CommandDeleteAgentDefinitionData = serde_json::from_value(data)
278                    .map_err(|e| format!("deleteagent: {e}"))?;
279                wstore.agent_def_delete(&cmd.id).map_err(|e| format!("deleteagent: {e}"))?;
280                broker.publish(crate::backend::wps::WaveEvent {
281                    event: "agents:changed".to_string(),
282                    scopes: vec![],
283                    sender: String::new(),
284                    persist: 0,
285                    data: None,
286                });
287                Ok(None)
288            })
289        }),
290    );
291
292    // agentdefcreatefromtemplate → clone a seeded template into a new
293    // user-owned definition (Phase 1 two-tier picker —
294    // SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md). The template stays
295    // pristine; the new row carries `is_seeded = 0`. Returns the new
296    // definition_id so the frontend can immediately launch.
297    //
298    // Validation rules:
299    //  - `template_id` MUST resolve to a row with `is_seeded = 1`.
300    //    Cloning a user-owned row would be confusing semantics — use
301    //    the existing `forkagentdefinition` RPC for that case.
302    //  - `name` non-empty, ≤200 chars, and not already taken by any
303    //    `is_seeded = 0` row. Avoids collisions in the picker's
304    //    "My Agents" list.
305    let wstore_act = state.wstore.clone();
306    let broker_act = state.broker.clone();
307    engine.register_handler(
308        COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
309        Box::new(move |data, _ctx| {
310            let wstore = wstore_act.clone();
311            let broker = broker_act.clone();
312            Box::pin(async move {
313                let cmd: CommandAgentDefCreateFromTemplateData = serde_json::from_value(data)
314                    .map_err(|e| format!("agentdefcreatefromtemplate: {e}"))?;
315                let name = cmd.name.trim().to_string();
316                if name.is_empty() {
317                    return Err("agentdefcreatefromtemplate: name must be non-empty".into());
318                }
319                if name.chars().count() > 200 {
320                    return Err(
321                        "agentdefcreatefromtemplate: name must be ≤200 characters".into(),
322                    );
323                }
324
325                let all = wstore
326                    .agent_def_list()
327                    .map_err(|e| format!("agentdefcreatefromtemplate: list: {e}"))?;
328                let template = all
329                    .iter()
330                    .find(|a| a.id == cmd.template_id)
331                    .ok_or_else(|| {
332                        format!(
333                            "agentdefcreatefromtemplate: template {} not found",
334                            cmd.template_id
335                        )
336                    })?;
337                if template.is_seeded != 1 {
338                    return Err(format!(
339                        "agentdefcreatefromtemplate: {} is not a seeded template (is_seeded={})",
340                        cmd.template_id, template.is_seeded
341                    ));
342                }
343                if all
344                    .iter()
345                    .any(|a| a.is_seeded == 0 && a.name.eq_ignore_ascii_case(&name))
346                {
347                    return Err(format!(
348                        "agentdefcreatefromtemplate: an agent named {:?} already exists",
349                        name
350                    ));
351                }
352
353                let now = SystemTime::now()
354                    .duration_since(UNIX_EPOCH)
355                    .map(|d| d.as_millis() as i64)
356                    .unwrap_or(0);
357                let mut new_def = AgentDefinition {
358                    id: uuid::Uuid::new_v4().to_string(),
359                    // agent_def_insert derives a unique slug from the
360                    // name when this is empty + collision-resolves.
361                    slug: String::new(),
362                    name: name.clone(),
363                    icon: template.icon.clone(),
364                    provider: template.provider.clone(),
365                    description: template.description.clone(),
366                    // Force re-allocation of the per-agent working
367                    // directory at first launch via the new slug —
368                    // matches forkagentdefinition's behaviour.
369                    working_directory: String::new(),
370                    shell: template.shell.clone(),
371                    provider_flags: template.provider_flags.clone(),
372                    // Users opt in to auto-start explicitly; cloning
373                    // shouldn't carry it over (mirrors fork).
374                    auto_start: 0,
375                    restart_on_crash: template.restart_on_crash,
376                    idle_timeout_minutes: template.idle_timeout_minutes,
377                    created_at: now,
378                    agent_type: template.agent_type.clone(),
379                    environment: template.environment.clone(),
380                    agent_bus_id: String::new(),
381                    is_seeded: 0,
382                    accounts: String::new(),
383                    parent_id: template.id.clone(),
384                    branch_label: String::new(),
385                    updated_at: now,
386                    // New user-owned agent starts visible. Phase 2
387                    // (Q2 Decision Y) — hide applies only to seeded
388                    // templates, never to user-owned agents.
389                    user_hidden: 0,
390                };
391                wstore
392                    .agent_def_insert(&mut new_def)
393                    .map_err(|e| format!("agentdefcreatefromtemplate: insert: {e}"))?;
394
395                broker.publish(crate::backend::wps::WaveEvent {
396                    event: "agents:changed".to_string(),
397                    scopes: vec![],
398                    sender: String::new(),
399                    persist: 0,
400                    data: None,
401                });
402
403                let resp = AgentDefCreateFromTemplateResult {
404                    definition_id: new_def.id.clone(),
405                    identity_id: cmd.identity_id,
406                    memory_id: cmd.memory_id,
407                };
408                tracing::info!(
409                    template_id = %cmd.template_id,
410                    new_definition_id = %new_def.id,
411                    new_name = %new_def.name,
412                    "agentdefcreatefromtemplate: cloned template into user agent"
413                );
414                Ok(Some(serde_json::to_value(&resp).unwrap_or_default()))
415            })
416        }),
417    );
418
419    // agentdefhide → set user_hidden = 1 on a seeded template, so it
420    // disappears from the picker's "+ New from template" tier. Phase 2
421    // (Q2 Decision Y) of SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md.
422    //
423    // Validation:
424    //  - `definition_id` MUST exist. Missing → returns `{ ok: false }`.
425    //  - The row MUST be a seeded template (`is_seeded = 1`). User-owned
426    //    rows reject with a hard error — they have their own delete path
427    //    and a hide flag on them would be misleading.
428    //
429    // Broadcasts `agents:changed` so the picker refetches and the card
430    // disappears (existing list query already excludes hidden by default).
431    let wstore_hide = state.wstore.clone();
432    let broker_hide = state.broker.clone();
433    engine.register_handler(
434        COMMAND_AGENT_DEF_HIDE,
435        Box::new(move |data, _ctx| {
436            let wstore = wstore_hide.clone();
437            let broker = broker_hide.clone();
438            Box::pin(async move {
439                let cmd: CommandAgentDefHideData = serde_json::from_value(data)
440                    .map_err(|e| format!("agentdefhide: {e}"))?;
441                let ok = wstore
442                    .agent_def_set_hidden(&cmd.definition_id, true)
443                    .map_err(|e| format!("agentdefhide: {e}"))?;
444                if ok {
445                    broker.publish(crate::backend::wps::WaveEvent {
446                        event: "agents:changed".to_string(),
447                        scopes: vec![],
448                        sender: String::new(),
449                        persist: 0,
450                        data: None,
451                    });
452                    tracing::info!(
453                        definition_id = %cmd.definition_id,
454                        "agentdefhide: hid template"
455                    );
456                }
457                let resp = AgentDefHideResult { ok };
458                Ok(Some(serde_json::to_value(&resp).unwrap_or_default()))
459            })
460        }),
461    );
462
463    // agentdefunhide → set user_hidden = 0 on a seeded template,
464    // bringing it back into the picker. Same validation + broadcast as
465    // agentdefhide. Phase 2 of the two-tier picker spec.
466    let wstore_unhide = state.wstore.clone();
467    let broker_unhide = state.broker.clone();
468    engine.register_handler(
469        COMMAND_AGENT_DEF_UNHIDE,
470        Box::new(move |data, _ctx| {
471            let wstore = wstore_unhide.clone();
472            let broker = broker_unhide.clone();
473            Box::pin(async move {
474                let cmd: CommandAgentDefHideData = serde_json::from_value(data)
475                    .map_err(|e| format!("agentdefunhide: {e}"))?;
476                let ok = wstore
477                    .agent_def_set_hidden(&cmd.definition_id, false)
478                    .map_err(|e| format!("agentdefunhide: {e}"))?;
479                if ok {
480                    broker.publish(crate::backend::wps::WaveEvent {
481                        event: "agents:changed".to_string(),
482                        scopes: vec![],
483                        sender: String::new(),
484                        persist: 0,
485                        data: None,
486                    });
487                    tracing::info!(
488                        definition_id = %cmd.definition_id,
489                        "agentdefunhide: unhid template"
490                    );
491                }
492                let resp = AgentDefHideResult { ok };
493                Ok(Some(serde_json::to_value(&resp).unwrap_or_default()))
494            })
495        }),
496    );
497
498    // agentdeflisthiddentemplates → templates the user has hidden
499    // (is_seeded = 1 AND user_hidden = 1). Used by the settings panel
500    // to render the unhide list. The picker proper never calls this —
501    // it uses `listagents` with the default-filter-out behaviour.
502    let wstore_lh = state.wstore.clone();
503    engine.register_handler(
504        COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
505        Box::new(move |_data, _ctx| {
506            let wstore = wstore_lh.clone();
507            Box::pin(async move {
508                let agents = wstore
509                    .agent_def_list()
510                    .map_err(|e| format!("agentdeflisthiddentemplates: {e}"))?;
511                let hidden: Vec<_> = agents
512                    .into_iter()
513                    .filter(|a| a.is_seeded == 1 && a.user_hidden == 1)
514                    .collect();
515                Ok(Some(serde_json::to_value(&hidden).unwrap_or_default()))
516            })
517        }),
518    );
519
520    // getagentcontent → return a single content blob for an agent
521    let wstore_gfc = state.wstore.clone();
522    engine.register_handler(
523        COMMAND_GET_AGENT_CONTENT,
524        Box::new(move |data, _ctx| {
525            let wstore = wstore_gfc.clone();
526            Box::pin(async move {
527                let cmd: CommandGetAgentContentData = serde_json::from_value(data)
528                    .map_err(|e| format!("getagentcontent: {e}"))?;
529                let content = wstore.agent_content_get(&cmd.agent_id, &cmd.content_type)
530                    .map_err(|e| format!("getagentcontent: {e}"))?;
531                Ok(content.map(|c| serde_json::to_value(&c).unwrap_or_default()))
532            })
533        }),
534    );
535
536    // setagentcontent → upsert a content blob, broadcast agentcontent:changed
537    let wstore_sfc = state.wstore.clone();
538    let broker_sfc = state.broker.clone();
539    engine.register_handler(
540        COMMAND_SET_AGENT_CONTENT,
541        Box::new(move |data, _ctx| {
542            let wstore = wstore_sfc.clone();
543            let broker = broker_sfc.clone();
544            Box::pin(async move {
545                let cmd: CommandSetAgentContentData = serde_json::from_value(data)
546                    .map_err(|e| format!("setagentcontent: {e}"))?;
547                let now = SystemTime::now()
548                    .duration_since(UNIX_EPOCH)
549                    .unwrap_or_default()
550                    .as_millis() as i64;
551                let content = AgentContent {
552                    agent_id: cmd.agent_id,
553                    content_type: cmd.content_type,
554                    content: cmd.content,
555                    updated_at: now,
556                };
557                wstore.agent_content_set(&content).map_err(|e| format!("setagentcontent: {e}"))?;
558                broker.publish(crate::backend::wps::WaveEvent {
559                    event: "agentcontent:changed".to_string(),
560                    scopes: vec![],
561                    sender: String::new(),
562                    persist: 0,
563                    data: None,
564                });
565                Ok(Some(serde_json::to_value(&content).unwrap_or_default()))
566            })
567        }),
568    );
569
570    // getallagentcontent → return all content blobs for an agent
571    let wstore_gafc = state.wstore.clone();
572    engine.register_handler(
573        COMMAND_GET_ALL_AGENT_CONTENT,
574        Box::new(move |data, _ctx| {
575            let wstore = wstore_gafc.clone();
576            Box::pin(async move {
577                let cmd: CommandGetAllAgentContentData = serde_json::from_value(data)
578                    .map_err(|e| format!("getallagentcontent: {e}"))?;
579                let contents = wstore.agent_content_get_all(&cmd.agent_id)
580                    .map_err(|e| format!("getallagentcontent: {e}"))?;
581                Ok(Some(serde_json::to_value(&contents).unwrap_or_default()))
582            })
583        }),
584    );
585
586    // ── Agent Skills handlers ──────────────────────────────────────────────
587
588    // listagentskills → return all skills for an agent
589    let wstore_lfs = state.wstore.clone();
590    engine.register_handler(
591        COMMAND_LIST_AGENT_SKILLS,
592        Box::new(move |data, _ctx| {
593            let wstore = wstore_lfs.clone();
594            Box::pin(async move {
595                let cmd: CommandListAgentSkillsData = serde_json::from_value(data)
596                    .map_err(|e| format!("listagentskills: {e}"))?;
597                let skills = wstore.agent_skill_list(&cmd.agent_id)
598                    .map_err(|e| format!("listagentskills: {e}"))?;
599                Ok(Some(serde_json::to_value(&skills).unwrap_or_default()))
600            })
601        }),
602    );
603
604    // createagentskill → insert new skill, broadcast agentskills:changed
605    let wstore_cfs = state.wstore.clone();
606    let broker_cfs = state.broker.clone();
607    engine.register_handler(
608        COMMAND_CREATE_AGENT_SKILL,
609        Box::new(move |data, _ctx| {
610            let wstore = wstore_cfs.clone();
611            let broker = broker_cfs.clone();
612            Box::pin(async move {
613                let cmd: CommandCreateAgentSkillData = serde_json::from_value(data)
614                    .map_err(|e| format!("createagentskill: {e}"))?;
615                let now = SystemTime::now()
616                    .duration_since(UNIX_EPOCH)
617                    .unwrap_or_default()
618                    .as_millis() as i64;
619                let skill = AgentSkill {
620                    id: uuid::Uuid::new_v4().to_string(),
621                    agent_id: cmd.agent_id,
622                    name: cmd.name,
623                    trigger: cmd.trigger,
624                    skill_type: cmd.skill_type,
625                    description: cmd.description,
626                    content: cmd.content,
627                    created_at: now,
628                };
629                wstore.agent_skill_insert(&skill).map_err(|e| format!("createagentskill: {e}"))?;
630                broker.publish(crate::backend::wps::WaveEvent {
631                    event: "agentskills:changed".to_string(),
632                    scopes: vec![],
633                    sender: String::new(),
634                    persist: 0,
635                    data: None,
636                });
637                Ok(Some(serde_json::to_value(&skill).unwrap_or_default()))
638            })
639        }),
640    );
641
642    // updateagentskill → update existing skill, broadcast agentskills:changed
643    let wstore_ufs = state.wstore.clone();
644    let broker_ufs = state.broker.clone();
645    engine.register_handler(
646        COMMAND_UPDATE_AGENT_SKILL,
647        Box::new(move |data, _ctx| {
648            let wstore = wstore_ufs.clone();
649            let broker = broker_ufs.clone();
650            Box::pin(async move {
651                let cmd: CommandUpdateAgentSkillData = serde_json::from_value(data)
652                    .map_err(|e| format!("updateagentskill: {e}"))?;
653                let existing = wstore.agent_skill_get(&cmd.id)
654                    .map_err(|e| format!("updateagentskill: {e}"))?
655                    .ok_or_else(|| format!("updateagentskill: skill {} not found", cmd.id))?;
656                let skill = AgentSkill {
657                    id: cmd.id,
658                    agent_id: existing.agent_id,
659                    name: cmd.name,
660                    trigger: cmd.trigger,
661                    skill_type: cmd.skill_type,
662                    description: cmd.description,
663                    content: cmd.content,
664                    created_at: existing.created_at,
665                };
666                let found = wstore.agent_skill_update(&skill).map_err(|e| format!("updateagentskill: {e}"))?;
667                if !found {
668                    return Err(format!("updateagentskill: skill {} not found", skill.id));
669                }
670                broker.publish(crate::backend::wps::WaveEvent {
671                    event: "agentskills:changed".to_string(),
672                    scopes: vec![],
673                    sender: String::new(),
674                    persist: 0,
675                    data: None,
676                });
677                Ok(Some(serde_json::to_value(&skill).unwrap_or_default()))
678            })
679        }),
680    );
681
682    // deleteagentskill → delete skill by id, broadcast agentskills:changed
683    let wstore_dfs = state.wstore.clone();
684    let broker_dfs = state.broker.clone();
685    engine.register_handler(
686        COMMAND_DELETE_AGENT_SKILL,
687        Box::new(move |data, _ctx| {
688            let wstore = wstore_dfs.clone();
689            let broker = broker_dfs.clone();
690            Box::pin(async move {
691                let cmd: CommandDeleteAgentSkillData = serde_json::from_value(data)
692                    .map_err(|e| format!("deleteagentskill: {e}"))?;
693                wstore.agent_skill_delete(&cmd.id).map_err(|e| format!("deleteagentskill: {e}"))?;
694                broker.publish(crate::backend::wps::WaveEvent {
695                    event: "agentskills:changed".to_string(),
696                    scopes: vec![],
697                    sender: String::new(),
698                    persist: 0,
699                    data: None,
700                });
701                Ok(None)
702            })
703        }),
704    );
705
706    // ── Agent History handlers ─────────────────────────────────────────────
707
708    // appendagenthistory → append a history entry, broadcast agenthistory:changed
709    let wstore_afh = state.wstore.clone();
710    let broker_afh = state.broker.clone();
711    engine.register_handler(
712        COMMAND_APPEND_AGENT_HISTORY,
713        Box::new(move |data, _ctx| {
714            let wstore = wstore_afh.clone();
715            let broker = broker_afh.clone();
716            Box::pin(async move {
717                let cmd: CommandAppendAgentHistoryData = serde_json::from_value(data)
718                    .map_err(|e| format!("appendagenthistory: {e}"))?;
719                let entry = wstore.agent_history_append(&cmd.agent_id, &cmd.entry)
720                    .map_err(|e| format!("appendagenthistory: {e}"))?;
721                broker.publish(crate::backend::wps::WaveEvent {
722                    event: "agenthistory:changed".to_string(),
723                    scopes: vec![],
724                    sender: String::new(),
725                    persist: 0,
726                    data: None,
727                });
728                Ok(Some(serde_json::to_value(&entry).unwrap_or_default()))
729            })
730        }),
731    );
732
733    // listagenthistory → return history entries with pagination
734    let wstore_lfh = state.wstore.clone();
735    engine.register_handler(
736        COMMAND_LIST_AGENT_HISTORY,
737        Box::new(move |data, _ctx| {
738            let wstore = wstore_lfh.clone();
739            Box::pin(async move {
740                let cmd: CommandListAgentHistoryData = serde_json::from_value(data)
741                    .map_err(|e| format!("listagenthistory: {e}"))?;
742                let entries = wstore.agent_history_list(
743                    &cmd.agent_id,
744                    cmd.session_date.as_deref(),
745                    cmd.limit,
746                    cmd.offset,
747                ).map_err(|e| format!("listagenthistory: {e}"))?;
748                Ok(Some(serde_json::to_value(&entries).unwrap_or_default()))
749            })
750        }),
751    );
752
753    // searchagenthistory → search history entries by query
754    let wstore_sfh = state.wstore.clone();
755    engine.register_handler(
756        COMMAND_SEARCH_AGENT_HISTORY,
757        Box::new(move |data, _ctx| {
758            let wstore = wstore_sfh.clone();
759            Box::pin(async move {
760                let cmd: CommandSearchAgentHistoryData = serde_json::from_value(data)
761                    .map_err(|e| format!("searchagenthistory: {e}"))?;
762                let entries = wstore.agent_history_search(&cmd.agent_id, &cmd.query, cmd.limit)
763                    .map_err(|e| format!("searchagenthistory: {e}"))?;
764                Ok(Some(serde_json::to_value(&entries).unwrap_or_default()))
765            })
766        }),
767    );
768
769    // ── Agent Import handler ───────────────────────────────────────────────
770
771    // importagentfromclaw → read claw workspace, create agent + content
772    let wstore_ifc = state.wstore.clone();
773    let broker_ifc = state.broker.clone();
774    engine.register_handler(
775        COMMAND_IMPORT_AGENT_FROM_CLAW,
776        Box::new(move |data, _ctx| {
777            let wstore = wstore_ifc.clone();
778            let broker = broker_ifc.clone();
779            Box::pin(async move {
780                let cmd: CommandImportAgentFromClawData = serde_json::from_value(data)
781                    .map_err(|e| format!("importagentfromclaw: {e}"))?;
782
783                let workspace_path = std::path::Path::new(&cmd.workspace_path);
784                if !workspace_path.exists() {
785                    return Err(format!("importagentfromclaw: path does not exist: {}", cmd.workspace_path));
786                }
787
788                let now = SystemTime::now()
789                    .duration_since(UNIX_EPOCH)
790                    .unwrap_or_default()
791                    .as_millis() as i64;
792
793                // Detect provider from .claude/settings.json if present
794                let mut provider = "claude".to_string();
795                let settings_path = workspace_path.join(".claude").join("settings.json");
796                if settings_path.exists() {
797                    if let Ok(settings_str) = std::fs::read_to_string(&settings_path) {
798                        if let Ok(settings) = serde_json::from_str::<serde_json::Value>(&settings_str) {
799                            if let Some(p) = settings.get("provider").and_then(|v| v.as_str()) {
800                                provider = p.to_string();
801                            }
802                        }
803                    }
804                }
805
806                // Create the agent — slug is empty, agent_def_insert will
807                // auto-derive from agent_name and mutate the struct
808                // so the resolved slug is returned to the frontend.
809                let mut agent = AgentDefinition {
810                    id: uuid::Uuid::new_v4().to_string(),
811                    slug: String::new(),
812                    name: cmd.agent_name.clone(),
813                    icon: "\u{2726}".to_string(),
814                    provider,
815                    description: format!("Imported from {}", cmd.workspace_path),
816                    working_directory: cmd.workspace_path.clone(),
817                    shell: String::new(),
818                    provider_flags: String::new(),
819                    auto_start: 0,
820                    restart_on_crash: 0,
821                    idle_timeout_minutes: 0,
822                    created_at: now,
823                    agent_type: "standalone".to_string(),
824                    environment: String::new(),
825                    agent_bus_id: String::new(),
826                    is_seeded: 0,
827                    accounts: String::new(),
828                    parent_id: String::new(),
829                    branch_label: String::new(),
830                    updated_at: now,
831                    user_hidden: 0,
832                };
833                wstore.agent_def_insert(&mut agent).map_err(|e| format!("importagentfromclaw: {e}"))?;
834
835                // Read CLAUDE.md → agentmd content
836                let claude_md_path = workspace_path.join("CLAUDE.md");
837                if claude_md_path.exists() {
838                    if let Ok(content) = std::fs::read_to_string(&claude_md_path) {
839                        let fc = AgentContent {
840                            agent_id: agent.id.clone(),
841                            content_type: "agentmd".to_string(),
842                            content,
843                            updated_at: now,
844                        };
845                        let _ = wstore.agent_content_set(&fc);
846                    }
847                }
848
849                // Read .mcp.json → mcp content
850                let mcp_path = workspace_path.join(".mcp.json");
851                if mcp_path.exists() {
852                    if let Ok(content) = std::fs::read_to_string(&mcp_path) {
853                        let fc = AgentContent {
854                            agent_id: agent.id.clone(),
855                            content_type: "mcp".to_string(),
856                            content,
857                            updated_at: now,
858                        };
859                        let _ = wstore.agent_content_set(&fc);
860                    }
861                }
862
863                broker.publish(crate::backend::wps::WaveEvent {
864                    event: "agents:changed".to_string(),
865                    scopes: vec![],
866                    sender: String::new(),
867                    persist: 0,
868                    data: None,
869                });
870                Ok(Some(serde_json::to_value(&agent).unwrap_or_default()))
871            })
872        }),
873    );
874
875    // reseedagents → delete all seeded agents and re-run seed from manifest
876    let wstore_rsfa = state.wstore.clone();
877    let broker_rsfa = state.broker.clone();
878    engine.register_handler(
879        COMMAND_RESEED_AGENTS,
880        Box::new(move |_data, _ctx| {
881            let wstore = wstore_rsfa.clone();
882            let broker = broker_rsfa.clone();
883            Box::pin(async move {
884                // Delete all previously seeded agents (cascade deletes content, skills, history)
885                let deleted = wstore.agent_def_delete_seeded()
886                    .map_err(|e| format!("reseedagents: delete seeded: {e}"))?;
887
888                // Re-run seed
889                let report = crate::backend::agent_seed::seed_agents(&wstore)
890                    .map_err(|e| format!("reseedagents: seed: {e}"))?;
891
892                broker.publish(crate::backend::wps::WaveEvent {
893                    event: "agents:changed".to_string(),
894                    scopes: vec![],
895                    sender: String::new(),
896                    persist: 0,
897                    data: None,
898                });
899                Ok(Some(json!({
900                    "deleted": deleted,
901                    "created": report.created,
902                    "skipped": report.skipped,
903                })))
904            })
905        }),
906    );
907
908    // importagents — bulk import from JSON export format
909    let wstore_ifa = state.wstore.clone();
910    let broker_ifa = state.broker.clone();
911    engine.register_handler(
912        COMMAND_IMPORT_AGENTS,
913        Box::new(move |data, _ctx| {
914            let wstore = wstore_ifa.clone();
915            let broker = broker_ifa.clone();
916            Box::pin(async move {
917                let cmd: CommandImportAgentDefinitionsData = serde_json::from_value(data)
918                    .map_err(|e| format!("importagents: {e}"))?;
919
920                let now = SystemTime::now()
921                    .duration_since(UNIX_EPOCH)
922                    .unwrap_or_default()
923                    .as_millis() as i64;
924
925                let mut imported: Vec<String> = Vec::new();
926                let mut skipped: Vec<String> = Vec::new();
927                let mut failed: Vec<String> = Vec::new();
928
929                for agent_import in cmd.agents {
930                    // Check for existing agent by slug (id field from export)
931                    let existing = wstore.agent_def_list()
932                        .unwrap_or_default()
933                        .into_iter()
934                        .any(|a| a.slug == agent_import.id);
935
936                    if existing {
937                        skipped.push(agent_import.name.clone());
938                        continue;
939                    }
940
941                    let mut agent = AgentDefinition {
942                        id: uuid::Uuid::new_v4().to_string(),
943                        slug: agent_import.id.clone(),
944                        name: agent_import.name.clone(),
945                        icon: agent_import.icon.clone(),
946                        provider: agent_import.provider.clone(),
947                        description: agent_import.description.clone(),
948                        working_directory: agent_import.working_directory.clone(),
949                        shell: agent_import.shell.clone(),
950                        provider_flags: String::new(),
951                        auto_start: 0,
952                        restart_on_crash: if agent_import.restart_on_crash { 1 } else { 0 },
953                        idle_timeout_minutes: 0,
954                        created_at: now,
955                        agent_type: agent_import.agent_type.clone(),
956                        environment: agent_import.environment.clone(),
957                        agent_bus_id: agent_import.agent_bus_id.clone(),
958                        is_seeded: 0,
959                        accounts: String::new(),
960                        parent_id: String::new(),
961                        branch_label: String::new(),
962                        updated_at: now,
963                        user_hidden: 0,
964                    };
965
966                    if let Err(e) = wstore.agent_def_insert(&mut agent) {
967                        failed.push(format!("{}: {e}", agent_import.name));
968                        continue;
969                    }
970
971                    // Insert content types
972                    let mut content_ok = true;
973                    for (content_type, content) in &agent_import.content {
974                        let fc = AgentContent {
975                            agent_id: agent.id.clone(),
976                            content_type: content_type.clone(),
977                            content: content.clone(),
978                            updated_at: now,
979                        };
980                        if let Err(e) = wstore.agent_content_set(&fc) {
981                            tracing::warn!("import: failed to set content for agent {}: {e}", agent.id);
982                            content_ok = false;
983                        }
984                    }
985
986                    // Insert skills
987                    let mut skills_ok = true;
988                    for skill_import in &agent_import.skills {
989                        let skill = AgentSkill {
990                            id: uuid::Uuid::new_v4().to_string(),
991                            agent_id: agent.id.clone(),
992                            name: skill_import.name.clone(),
993                            trigger: skill_import.trigger.clone(),
994                            skill_type: skill_import.skill_type.clone(),
995                            description: skill_import.description.clone(),
996                            content: skill_import.content.clone(),
997                            created_at: now,
998                        };
999                        if let Err(e) = wstore.agent_skill_insert(&skill) {
1000                            tracing::warn!("import: failed to insert skill '{}' for agent {}: {e}", skill.name, agent.id);
1001                            skills_ok = false;
1002                        }
1003                    }
1004
1005                    if content_ok && skills_ok {
1006                        imported.push(agent_import.name.clone());
1007                    } else {
1008                        failed.push(agent_import.name.clone());
1009                    }
1010                }
1011
1012                broker.publish(crate::backend::wps::WaveEvent {
1013                    event: "agents:changed".to_string(),
1014                    scopes: vec![],
1015                    sender: String::new(),
1016                    persist: 0,
1017                    data: None,
1018                });
1019
1020                let result = ImportAgentDefinitionsResult { imported, skipped, failed };
1021                Ok(Some(serde_json::to_value(&result).unwrap_or_default()))
1022            })
1023        }),
1024    );
1025
1026    // exportagents — export all agent definitions with content and skills
1027    let wstore_efa = state.wstore.clone();
1028    engine.register_handler(
1029        COMMAND_EXPORT_AGENTS,
1030        Box::new(move |_data, _ctx| {
1031            let wstore = wstore_efa.clone();
1032            Box::pin(async move {
1033                let agents = wstore.agent_def_list()
1034                    .map_err(|e| format!("exportagents: list: {e}"))?;
1035
1036                let mut agent_exports: Vec<AgentDefinitionExport> = Vec::new();
1037
1038                for agent in agents {
1039                    let content_map = wstore.agent_content_get_all(&agent.id)
1040                        .unwrap_or_default()
1041                        .into_iter()
1042                        .map(|fc| (fc.content_type, fc.content))
1043                        .collect::<std::collections::HashMap<String, String>>();
1044
1045                    let skills = wstore.agent_skill_list(&agent.id)
1046                        .unwrap_or_default()
1047                        .into_iter()
1048                        .map(|s| AgentSkillExport {
1049                            name: s.name,
1050                            trigger: s.trigger,
1051                            skill_type: s.skill_type,
1052                            description: s.description,
1053                            content: s.content,
1054                        })
1055                        .collect::<Vec<_>>();
1056
1057                    agent_exports.push(AgentDefinitionExport {
1058                        id: agent.slug.clone(),
1059                        name: agent.name,
1060                        icon: agent.icon,
1061                        description: agent.description,
1062                        provider: agent.provider,
1063                        shell: agent.shell,
1064                        working_directory: agent.working_directory,
1065                        agent_bus_id: agent.agent_bus_id,
1066                        agent_type: agent.agent_type,
1067                        environment: agent.environment,
1068                        restart_on_crash: agent.restart_on_crash != 0,
1069                        content: content_map,
1070                        skills,
1071                    });
1072                }
1073
1074                let exported_at = Utc::now().to_rfc3339();
1075
1076                let result = ExportAgentDefinitionsResult {
1077                    version: 4,
1078                    exported_at,
1079                    source: "agentmux-export".to_string(),
1080                    agents: agent_exports,
1081                };
1082                Ok(Some(serde_json::to_value(&result).unwrap_or_default()))
1083            })
1084        }),
1085    );
1086
1087    register_v6_handlers(engine, state);
1088}
1089
1090/// v6 handlers — identity accounts, agent instances, definition branching.
1091/// See specs/SPEC_FORGE_IDENTITY_AGENT_INSTANCES_IMPL_2026_04_20.md §Phase 3.
1092fn register_v6_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
1093    // ---- Identity account CRUD ----
1094
1095    let wstore = state.wstore.clone();
1096    engine.register_handler(
1097        COMMAND_LIST_IDENTITY_ACCOUNTS,
1098        Box::new(move |data, _ctx| {
1099            let wstore = wstore.clone();
1100            Box::pin(async move {
1101                let cmd: CommandListIdentityAccountsData =
1102                    serde_json::from_value(data).unwrap_or_default();
1103                let accounts = wstore
1104                    .identity_list(cmd.provider.as_deref())
1105                    .map_err(|e| format!("listidentityaccounts: {e}"))?;
1106                Ok(Some(serde_json::to_value(&accounts).unwrap_or_default()))
1107            })
1108        }),
1109    );
1110
1111    let wstore = state.wstore.clone();
1112    engine.register_handler(
1113        COMMAND_GET_IDENTITY_ACCOUNT,
1114        Box::new(move |data, _ctx| {
1115            let wstore = wstore.clone();
1116            Box::pin(async move {
1117                let cmd: CommandGetIdentityAccountData =
1118                    serde_json::from_value(data).map_err(|e| format!("getidentityaccount: {e}"))?;
1119                match wstore
1120                    .identity_get(&cmd.id)
1121                    .map_err(|e| format!("getidentityaccount: {e}"))?
1122                {
1123                    Some(a) => Ok(Some(serde_json::to_value(&a).unwrap_or_default())),
1124                    None => Err(format!("getidentityaccount: not found id={}", cmd.id)),
1125                }
1126            })
1127        }),
1128    );
1129
1130    let wstore = state.wstore.clone();
1131    let broker = state.broker.clone();
1132    engine.register_handler(
1133        COMMAND_UPSERT_IDENTITY_ACCOUNT,
1134        Box::new(move |data, _ctx| {
1135            let wstore = wstore.clone();
1136            let broker = broker.clone();
1137            Box::pin(async move {
1138                // Accept the full IdentityAccount payload. Missing `id` → mint
1139                // a fresh UUID; `created_at` and `updated_at` are server-set
1140                // so callers don't have to know the current time.
1141                let mut account: IdentityAccount = serde_json::from_value(data)
1142                    .map_err(|e| format!("upsertidentityaccount: {e}"))?;
1143                if account.id.is_empty() {
1144                    account.id = uuid::Uuid::new_v4().to_string();
1145                }
1146                let now = SystemTime::now()
1147                    .duration_since(UNIX_EPOCH)
1148                    .map(|d| d.as_millis() as i64)
1149                    .unwrap_or(0);
1150                if account.created_at == 0 {
1151                    account.created_at = now;
1152                }
1153                account.updated_at = now;
1154                wstore
1155                    .identity_upsert(&account)
1156                    .map_err(|e| format!("upsertidentityaccount: {e}"))?;
1157                broker.publish(crate::backend::wps::WaveEvent {
1158                    event: "identityaccounts:changed".to_string(),
1159                    scopes: vec![],
1160                    sender: String::new(),
1161                    persist: 0,
1162                    data: None,
1163                });
1164                Ok(Some(serde_json::to_value(&account).unwrap_or_default()))
1165            })
1166        }),
1167    );
1168
1169    let wstore = state.wstore.clone();
1170    let broker = state.broker.clone();
1171    engine.register_handler(
1172        COMMAND_DELETE_IDENTITY_ACCOUNT,
1173        Box::new(move |data, _ctx| {
1174            let wstore = wstore.clone();
1175            let broker = broker.clone();
1176            Box::pin(async move {
1177                let cmd: CommandDeleteIdentityAccountData = serde_json::from_value(data)
1178                    .map_err(|e| format!("deleteidentityaccount: {e}"))?;
1179                let deleted = wstore
1180                    .identity_delete(&cmd.id)
1181                    .map_err(|e| format!("deleteidentityaccount: {e}"))?;
1182                if deleted {
1183                    broker.publish(crate::backend::wps::WaveEvent {
1184                        event: "identityaccounts:changed".to_string(),
1185                        scopes: vec![],
1186                        sender: String::new(),
1187                        persist: 0,
1188                        data: None,
1189                    });
1190                }
1191                Ok(Some(json!({ "deleted": deleted })))
1192            })
1193        }),
1194    );
1195
1196    // ---- Agent ↔ Identity junction ----
1197
1198    let wstore = state.wstore.clone();
1199    let broker = state.broker.clone();
1200    engine.register_handler(
1201        COMMAND_LINK_AGENT_IDENTITY,
1202        Box::new(move |data, _ctx| {
1203            let wstore = wstore.clone();
1204            let broker = broker.clone();
1205            Box::pin(async move {
1206                let cmd: CommandLinkAgentIdentityData = serde_json::from_value(data)
1207                    .map_err(|e| format!("linkagentidentity: {e}"))?;
1208                wstore
1209                    .agent_identity_link(&cmd.agent_id, &cmd.account_id, &cmd.provider)
1210                    .map_err(|e| format!("linkagentidentity: {e}"))?;
1211                broker.publish(crate::backend::wps::WaveEvent {
1212                    event: format!("agentidentities:changed:{}", cmd.agent_id),
1213                    scopes: vec![],
1214                    sender: String::new(),
1215                    persist: 0,
1216                    data: None,
1217                });
1218                Ok(None)
1219            })
1220        }),
1221    );
1222
1223    let wstore = state.wstore.clone();
1224    let broker = state.broker.clone();
1225    engine.register_handler(
1226        COMMAND_UNLINK_AGENT_IDENTITY,
1227        Box::new(move |data, _ctx| {
1228            let wstore = wstore.clone();
1229            let broker = broker.clone();
1230            Box::pin(async move {
1231                let cmd: CommandUnlinkAgentIdentityData = serde_json::from_value(data)
1232                    .map_err(|e| format!("unlinkagentidentity: {e}"))?;
1233                let removed = wstore
1234                    .agent_identity_unlink(&cmd.agent_id, &cmd.provider)
1235                    .map_err(|e| format!("unlinkagentidentity: {e}"))?;
1236                if removed {
1237                    broker.publish(crate::backend::wps::WaveEvent {
1238                        event: format!("agentidentities:changed:{}", cmd.agent_id),
1239                        scopes: vec![],
1240                        sender: String::new(),
1241                        persist: 0,
1242                        data: None,
1243                    });
1244                }
1245                Ok(Some(json!({ "unlinked": removed })))
1246            })
1247        }),
1248    );
1249
1250    let wstore = state.wstore.clone();
1251    engine.register_handler(
1252        COMMAND_LIST_AGENT_IDENTITIES,
1253        Box::new(move |data, _ctx| {
1254            let wstore = wstore.clone();
1255            Box::pin(async move {
1256                let cmd: CommandListAgentIdentitiesData = serde_json::from_value(data)
1257                    .map_err(|e| format!("listagentidentities: {e}"))?;
1258                let rows = wstore
1259                    .agent_identity_list_for_agent(&cmd.agent_id)
1260                    .map_err(|e| format!("listagentidentities: {e}"))?;
1261                Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
1262            })
1263        }),
1264    );
1265
1266    // ---- Agent instance CRUD ----
1267
1268    let wstore = state.wstore.clone();
1269    engine.register_handler(
1270        COMMAND_LIST_AGENT_INSTANCES,
1271        Box::new(move |data, _ctx| {
1272            let wstore = wstore.clone();
1273            Box::pin(async move {
1274                let cmd: CommandListAgentInstancesData =
1275                    serde_json::from_value(data).unwrap_or_default();
1276                let rows = wstore
1277                    .instance_list(cmd.definition_id.as_deref(), cmd.status.as_deref())
1278                    .map_err(|e| format!("listagentinstances: {e}"))?;
1279                Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
1280            })
1281        }),
1282    );
1283
1284    let wstore = state.wstore.clone();
1285    engine.register_handler(
1286        COMMAND_GET_AGENT_INSTANCE,
1287        Box::new(move |data, _ctx| {
1288            let wstore = wstore.clone();
1289            Box::pin(async move {
1290                let cmd: CommandGetAgentInstanceData = serde_json::from_value(data)
1291                    .map_err(|e| format!("getagentinstance: {e}"))?;
1292                match wstore
1293                    .instance_get(&cmd.id)
1294                    .map_err(|e| format!("getagentinstance: {e}"))?
1295                {
1296                    Some(i) => Ok(Some(serde_json::to_value(&i).unwrap_or_default())),
1297                    None => Err(format!("getagentinstance: not found id={}", cmd.id)),
1298                }
1299            })
1300        }),
1301    );
1302
1303    let wstore = state.wstore.clone();
1304    let broker = state.broker.clone();
1305    engine.register_handler(
1306        COMMAND_CREATE_AGENT_INSTANCE,
1307        Box::new(move |data, _ctx| {
1308            let wstore = wstore.clone();
1309            let broker = broker.clone();
1310            Box::pin(async move {
1311                let cmd: CommandCreateAgentInstanceData = serde_json::from_value(data)
1312                    .map_err(|e| format!("createagentinstance: {e}"))?;
1313                let now = SystemTime::now()
1314                    .duration_since(UNIX_EPOCH)
1315                    .map(|d| d.as_millis() as i64)
1316                    .unwrap_or(0);
1317                let inst = AgentInstance {
1318                    id: uuid::Uuid::new_v4().to_string(),
1319                    definition_id: cmd.definition_id,
1320                    parent_instance_id: cmd.parent_instance_id,
1321                    block_id: cmd.block_id,
1322                    session_id: String::new(),
1323                    status: InstanceStatus::Running.as_str().to_string(),
1324                    github_context: String::new(),
1325                    started_at: now,
1326                    ended_at: 0,
1327                    created_at: now,
1328                    // PR-F.3: launch modal passes through Identity +
1329                    // Memory bundle picks. Empty string = blank
1330                    // singleton (no override; the resolver returns
1331                    // immediately on either "" or "blank").
1332                    identity_id: cmd.identity_id,
1333                    memory_id: cmd.memory_id,
1334                    // v8: named-agent continuation. instance_name +
1335                    // working_directory come from the launch-modal
1336                    // overrides via CommandCreateAgentInstanceData
1337                    // (added in the same spec). Empty string for
1338                    // legacy/ambient launches.
1339                    instance_name: cmd.instance_name.clone(),
1340                    working_directory: cmd.working_directory.clone(),
1341                    display_hidden: false,
1342                };
1343                wstore
1344                    .instance_create(&inst)
1345                    .map_err(|e| format!("createagentinstance: {e}"))?;
1346
1347                // Option E (PR 1 of 2) — stamp the agent-anchored
1348                // session zone reference onto the block meta. Every
1349                // block of this agent definition reads/writes through
1350                // `agent:<defId>:current`. Continuation is now
1351                // structural (same zone, different block) rather than
1352                // parametric (per-block snapshot copy + --continue).
1353                // See docs/specs/SPEC_CONTINUATION_SESSION_PERSISTENCE_2026_05_23.md.
1354                if !inst.block_id.is_empty()
1355                    && crate::backend::agent_session::is_valid_definition_id(&inst.definition_id)
1356                {
1357                    let zone = crate::backend::agent_session::agent_current_zone(
1358                        &inst.definition_id,
1359                    );
1360                    let mut meta_update = crate::backend::obj::MetaMapType::new();
1361                    meta_update.insert(
1362                        "agent:sessionZone".to_string(),
1363                        serde_json::json!(zone),
1364                    );
1365                    let oref_str = format!("block:{}", inst.block_id);
1366                    if let Err(e) = crate::server::service::update_object_meta(
1367                        &wstore, &oref_str, &meta_update,
1368                    ) {
1369                        // Non-fatal — the instance row is the source
1370                        // of truth, the meta stamp is a frontend
1371                        // convenience. Log + continue so the launch
1372                        // doesn't abort mid-flow.
1373                        tracing::warn!(
1374                            block_id = %inst.block_id,
1375                            definition_id = %inst.definition_id,
1376                            error = %e,
1377                            "createagentinstance: failed to stamp agent:sessionZone"
1378                        );
1379                    }
1380                }
1381
1382                broker.publish(crate::backend::wps::WaveEvent {
1383                    event: format!("agentinstances:changed:{}", inst.definition_id),
1384                    scopes: vec![],
1385                    sender: String::new(),
1386                    persist: 0,
1387                    data: None,
1388                });
1389                Ok(Some(serde_json::to_value(&inst).unwrap_or_default()))
1390            })
1391        }),
1392    );
1393
1394    let wstore = state.wstore.clone();
1395    let broker = state.broker.clone();
1396    engine.register_handler(
1397        COMMAND_UPDATE_AGENT_INSTANCE,
1398        Box::new(move |data, _ctx| {
1399            let wstore = wstore.clone();
1400            let broker = broker.clone();
1401            Box::pin(async move {
1402                let cmd: CommandUpdateAgentInstanceData = serde_json::from_value(data)
1403                    .map_err(|e| format!("updateagentinstance: {e}"))?;
1404                let existing = wstore
1405                    .instance_get(&cmd.id)
1406                    .map_err(|e| format!("updateagentinstance: {e}"))?
1407                    .ok_or_else(|| format!("updateagentinstance: not found id={}", cmd.id))?;
1408                let merged = AgentInstance {
1409                    id: existing.id.clone(),
1410                    definition_id: existing.definition_id.clone(),
1411                    parent_instance_id: existing.parent_instance_id.clone(),
1412                    block_id: cmd.block_id.unwrap_or(existing.block_id),
1413                    session_id: cmd.session_id.unwrap_or(existing.session_id),
1414                    status: cmd.status.unwrap_or(existing.status),
1415                    github_context: cmd.github_context.unwrap_or(existing.github_context),
1416                    started_at: existing.started_at,
1417                    ended_at: cmd.ended_at.unwrap_or(existing.ended_at),
1418                    created_at: existing.created_at,
1419                    // identity_id / memory_id / instance_name /
1420                    // working_directory are immutable post-create
1421                    // (mid-session credential rotation is out of scope
1422                    // — launch a new instance with a different bundle
1423                    // or use ContinueNamedAgentCommand). display_hidden
1424                    // is mutated via instance_set_hidden, not here.
1425                    identity_id: existing.identity_id.clone(),
1426                    memory_id: existing.memory_id.clone(),
1427                    instance_name: existing.instance_name.clone(),
1428                    working_directory: existing.working_directory.clone(),
1429                    display_hidden: existing.display_hidden,
1430                };
1431                wstore
1432                    .instance_update(&merged)
1433                    .map_err(|e| format!("updateagentinstance: {e}"))?;
1434                broker.publish(crate::backend::wps::WaveEvent {
1435                    event: format!("agentinstances:changed:{}", merged.definition_id),
1436                    scopes: vec![],
1437                    sender: String::new(),
1438                    persist: 0,
1439                    data: None,
1440                });
1441                Ok(Some(serde_json::to_value(&merged).unwrap_or_default()))
1442            })
1443        }),
1444    );
1445
1446    let wstore = state.wstore.clone();
1447    let broker = state.broker.clone();
1448    engine.register_handler(
1449        COMMAND_DELETE_AGENT_INSTANCE,
1450        Box::new(move |data, _ctx| {
1451            let wstore = wstore.clone();
1452            let broker = broker.clone();
1453            Box::pin(async move {
1454                let cmd: CommandDeleteAgentInstanceData = serde_json::from_value(data)
1455                    .map_err(|e| format!("deleteagentinstance: {e}"))?;
1456                // Read the row first so we can emit a scoped event after.
1457                let definition_id = wstore
1458                    .instance_get(&cmd.id)
1459                    .map_err(|e| format!("deleteagentinstance: {e}"))?
1460                    .map(|i| i.definition_id);
1461                let deleted = wstore
1462                    .instance_delete(&cmd.id)
1463                    .map_err(|e| format!("deleteagentinstance: {e}"))?;
1464                if let Some(def_id) = definition_id.filter(|_| deleted) {
1465                    broker.publish(crate::backend::wps::WaveEvent {
1466                        event: format!("agentinstances:changed:{}", def_id),
1467                        scopes: vec![],
1468                        sender: String::new(),
1469                        persist: 0,
1470                        data: None,
1471                    });
1472                }
1473                Ok(Some(json!({ "deleted": deleted })))
1474            })
1475        }),
1476    );
1477
1478    // ---- v8: named agent continuation ----
1479
1480    // listnamedagents — powers the launch modal's "Continue agent"
1481    // dropdown. Joins instance rows with the definition / identity /
1482    // memory bundle names so the frontend renders without follow-ups.
1483    let wstore = state.wstore.clone();
1484    engine.register_handler(
1485        COMMAND_LIST_NAMED_AGENTS,
1486        Box::new(move |data, _ctx| {
1487            let wstore = wstore.clone();
1488            Box::pin(async move {
1489                let cmd: CommandListNamedAgentsData =
1490                    serde_json::from_value(data).unwrap_or_default();
1491                let limit = if cmd.limit == 0 {
1492                    200
1493                } else {
1494                    cmd.limit.min(1000)
1495                };
1496                // Resolve bundle names once per response. With ≤200
1497                // rows and typical bundle counts in the low dozens,
1498                // a linear lookup on cached lists beats per-row
1499                // round-trips through the store.
1500                let defs = wstore
1501                    .agent_def_list()
1502                    .map_err(|e| format!("listnamedagents: agent_def_list: {e}"))?;
1503                let identities = wstore
1504                    .bundle_identity_list()
1505                    .map_err(|e| format!("listnamedagents: bundle_identity_list: {e}"))?;
1506                let memories = wstore
1507                    .bundle_memory_list()
1508                    .map_err(|e| format!("listnamedagents: bundle_memory_list: {e}"))?;
1509
1510                // PR B — read from the cross-version registry when
1511                // it's available. Falls back to SQLite when the
1512                // registry couldn't be resolved at startup (CI / odd
1513                // environments). SQLite remains authoritative for
1514                // PR B (parallel-write is still active); the choice
1515                // here just affects which surface gets surfaced.
1516                let rows: Vec<NamedAgentRow> = match wstore.shared_agent_registry() {
1517                    Some(reg) => {
1518                        let agents_root = reg.agents_root().map(|p| p.to_path_buf());
1519                        let mut records = reg
1520                            .list_active()
1521                            .map_err(|e| format!("listnamedagents: registry: {e}"))?;
1522                        if let Some(def_filter) = cmd.definition_id.as_deref() {
1523                            records.retain(|r| r.data.definition_id == def_filter);
1524                        }
1525                        records.sort_by(|a, b| {
1526                            b.data
1527                                .last_launched_at_ms
1528                                .cmp(&a.data.last_launched_at_ms)
1529                        });
1530                        records.truncate(limit);
1531                        // Pre-fetch all candidate same-version rows
1532                        // ONCE so enrichment doesn't issue N+1 queries.
1533                        // Indexed by instance_id; rows that aren't in
1534                        // current SQLite fall through to sentinels.
1535                        // Registry enrichment: keep head-of-chain
1536                        // only. The registry mirror itself excludes
1537                        // continuations (see
1538                        // `registry_upsert_if_named`), so the SQLite
1539                        // side must match — else under the `limit`
1540                        // truncation continuation rows displace
1541                        // registry-head rows and the merge-by-id
1542                        // enrichment misses, silently downgrading
1543                        // running-state badges and block_id_hints to
1544                        // "available" / empty.
1545                        let sqlite_rows: Vec<AgentInstance> = wstore
1546                            .instance_list_named(
1547                                records.len().max(1),
1548                                cmd.definition_id.as_deref(),
1549                                /* include_continuations */ false,
1550                            )
1551                            .unwrap_or_default();
1552                        let sqlite_by_id: std::collections::HashMap<&str, &AgentInstance> =
1553                            sqlite_rows.iter().map(|i| (i.id.as_str(), i)).collect();
1554                        records
1555                            .into_iter()
1556                            .map(|rec| {
1557                                let d = rec.data;
1558                                let def = defs.iter().find(|x| x.id == d.definition_id);
1559                                let identity_id_str =
1560                                    d.identity_id.clone().unwrap_or_default();
1561                                let memory_id_str = d.memory_id.clone().unwrap_or_default();
1562                                let identity_name = if identity_id_str.is_empty() {
1563                                    "(ambient creds)".to_string()
1564                                } else {
1565                                    identities
1566                                        .iter()
1567                                        .find(|i| i.id == identity_id_str)
1568                                        .map(|i| i.name.clone())
1569                                        .unwrap_or_else(|| "(missing identity)".to_string())
1570                                };
1571                                let memory_name = if memory_id_str.is_empty() {
1572                                    "(vanilla CLI)".to_string()
1573                                } else {
1574                                    memories
1575                                        .iter()
1576                                        .find(|m| m.id == memory_id_str)
1577                                        .map(|m| m.name.clone())
1578                                        .unwrap_or_else(|| "(missing memory)".to_string())
1579                                };
1580                                let working_directory = match agents_root.as_ref() {
1581                                    Some(root) => root
1582                                        .join(&d.working_dir)
1583                                        .to_string_lossy()
1584                                        .to_string(),
1585                                    None => d.working_dir.clone(),
1586                                };
1587                                // Same-version enrichment: if this id
1588                                // also exists in current SQLite, the
1589                                // row carries runtime state (block_id
1590                                // for focus-existing-pane, status,
1591                                // ended_at) that the registry
1592                                // intentionally doesn't track.
1593                                // Cross-version rows fall through with
1594                                // sentinel "available" status and
1595                                // empty block_id_hint.
1596                                let (block_id_hint, status, ended_at) =
1597                                    match sqlite_by_id.get(d.instance_id.as_str()) {
1598                                        Some(inst) => (
1599                                            inst.block_id.clone(),
1600                                            inst.status.clone(),
1601                                            inst.ended_at,
1602                                        ),
1603                                        None => (String::new(), "available".to_string(), 0),
1604                                    };
1605                                NamedAgentRow {
1606                                    instance_id: d.instance_id,
1607                                    instance_name: d.instance_name,
1608                                    definition_id: d.definition_id.clone(),
1609                                    definition_name: def
1610                                        .map(|x| x.name.clone())
1611                                        .unwrap_or_else(|| "(missing definition)".to_string()),
1612                                    provider: def
1613                                        .map(|x| x.provider.clone())
1614                                        .unwrap_or_default(),
1615                                    working_directory,
1616                                    identity_id: identity_id_str,
1617                                    identity_name,
1618                                    memory_id: memory_id_str,
1619                                    memory_name,
1620                                    started_at: d.last_launched_at_ms,
1621                                    ended_at,
1622                                    status,
1623                                    block_id_hint,
1624                                }
1625                            })
1626                            .collect()
1627                    }
1628                    None => {
1629                        // No-registry fallback: drives the launch
1630                        // modal's "Continue agent" dropdown directly.
1631                        // One entry per chain root, mirroring the
1632                        // registry path's semantics.
1633                        let instances = wstore
1634                            .instance_list_named(
1635                                limit,
1636                                cmd.definition_id.as_deref(),
1637                                /* include_continuations */ false,
1638                            )
1639                            .map_err(|e| format!("listnamedagents: {e}"))?;
1640                        instances
1641                            .into_iter()
1642                            .map(|inst| {
1643                                let def = defs.iter().find(|d| d.id == inst.definition_id);
1644                                let identity_name = if inst.identity_id.is_empty() {
1645                                    "(ambient creds)".to_string()
1646                                } else {
1647                                    identities
1648                                        .iter()
1649                                        .find(|i| i.id == inst.identity_id)
1650                                        .map(|i| i.name.clone())
1651                                        .unwrap_or_else(|| "(missing identity)".to_string())
1652                                };
1653                                let memory_name = if inst.memory_id.is_empty() {
1654                                    "(vanilla CLI)".to_string()
1655                                } else {
1656                                    memories
1657                                        .iter()
1658                                        .find(|m| m.id == inst.memory_id)
1659                                        .map(|m| m.name.clone())
1660                                        .unwrap_or_else(|| "(missing memory)".to_string())
1661                                };
1662                                NamedAgentRow {
1663                                    instance_id: inst.id,
1664                                    instance_name: inst.instance_name,
1665                                    definition_id: inst.definition_id.clone(),
1666                                    definition_name: def
1667                                        .map(|d| d.name.clone())
1668                                        .unwrap_or_else(|| "(missing definition)".to_string()),
1669                                    provider: def
1670                                        .map(|d| d.provider.clone())
1671                                        .unwrap_or_default(),
1672                                    working_directory: inst.working_directory,
1673                                    identity_id: inst.identity_id,
1674                                    identity_name,
1675                                    memory_id: inst.memory_id,
1676                                    memory_name,
1677                                    started_at: inst.started_at,
1678                                    ended_at: inst.ended_at,
1679                                    status: inst.status,
1680                                    block_id_hint: inst.block_id,
1681                                }
1682                            })
1683                            .collect()
1684                    }
1685                };
1686
1687                Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
1688            })
1689        }),
1690    );
1691
1692    // hidenamedagent — soft-delete (sets display_hidden = 1) so the
1693    // row disappears from the dropdown. Working dir stays on disk.
1694    let wstore = state.wstore.clone();
1695    let broker = state.broker.clone();
1696    engine.register_handler(
1697        COMMAND_HIDE_NAMED_AGENT,
1698        Box::new(move |data, _ctx| {
1699            let wstore = wstore.clone();
1700            let broker = broker.clone();
1701            Box::pin(async move {
1702                let cmd: CommandHideNamedAgentData = serde_json::from_value(data)
1703                    .map_err(|e| format!("hidenamedagent: {e}"))?;
1704                let hidden = wstore
1705                    .instance_set_hidden(&cmd.id, true)
1706                    .map_err(|e| format!("hidenamedagent: {e}"))?;
1707                if hidden {
1708                    broker.publish(crate::backend::wps::WaveEvent {
1709                        event: "namedagents:changed".to_string(),
1710                        scopes: vec![],
1711                        sender: String::new(),
1712                        persist: 0,
1713                        data: None,
1714                    });
1715                }
1716                Ok(Some(json!({ "hidden": hidden })))
1717            })
1718        }),
1719    );
1720
1721    // ---- Recent sessions (cascade follow-up 2026-05-23) ----
1722    //
1723    // listrecentsessions — joins `db_agent_instances` with the
1724    // filestore `output.state.json` snapshot for each instance's
1725    // block_id_hint, producing a preview + node count so the
1726    // AgentPicker can show actual conversation context instead of just
1727    // metadata. Sort key is the snapshot modts (last activity)
1728    // descending; rows without a snapshot fall back to the instance
1729    // started_at and are de-prioritized. Cap at 20 rows.
1730    //
1731    // The reattach mechanism is the existing continuation flow:
1732    // continueOfInstanceId + workDirOverride (see PR #977). This RPC
1733    // is a more discoverable surface for finding sessions to continue
1734    // — particularly orphaned ones whose pane crashed.
1735    let wstore = state.wstore.clone();
1736    let filestore = state.filestore.clone();
1737    engine.register_handler(
1738        COMMAND_LIST_RECENT_SESSIONS,
1739        Box::new(move |data, _ctx| {
1740            let wstore = wstore.clone();
1741            let filestore = filestore.clone();
1742            Box::pin(async move {
1743                let cmd: CommandListRecentSessionsData =
1744                    serde_json::from_value(data).unwrap_or_default();
1745                let limit = if cmd.limit == 0 {
1746                    20
1747                } else {
1748                    cmd.limit.min(100)
1749                };
1750                // Pull up to ~10x the requested cap so we can post-
1751                // filter by snapshot presence + identity_id without
1752                // running out of candidates. 10x is a safety margin
1753                // and stays well inside the 200 default of
1754                // instance_list_named.
1755                let raw_limit = (limit * 10).max(50).min(500);
1756                let instances = wstore
1757                    // Picker "My Agents": include continuations.
1758                    // Under Option E a continuation row is the most-
1759                    // recent named instance of an agent the user
1760                    // actively used — exactly what we want surfaced.
1761                    .instance_list_named(raw_limit, None, /* include_continuations */ true)
1762                    .map_err(|e| format!("listrecentsessions: {e}"))?;
1763
1764                let defs = wstore
1765                    .agent_def_list()
1766                    .map_err(|e| format!("listrecentsessions: defs: {e}"))?;
1767                let identities = wstore
1768                    .bundle_identity_list()
1769                    .map_err(|e| format!("listrecentsessions: identities: {e}"))?;
1770                let memories = wstore
1771                    .bundle_memory_list()
1772                    .map_err(|e| format!("listrecentsessions: memories: {e}"))?;
1773
1774                let identity_filter = cmd
1775                    .identity_id
1776                    .as_deref()
1777                    .map(|s| s.trim())
1778                    .filter(|s| !s.is_empty());
1779
1780                // Build rows. Hits filestore once per instance; with
1781                // raw_limit ≤ 500 and stat() being a single indexed
1782                // SQLite query, the per-call cost is dominated by
1783                // the eventual snapshot read for the top-20.
1784                let mut rows: Vec<RecentSessionRow> = Vec::with_capacity(instances.len());
1785                for inst in instances {
1786                    // Identity filter — applied before any filestore
1787                    // I/O so the rejected rows don't pay for a stat.
1788                    if let Some(filter) = identity_filter {
1789                        if inst.identity_id != filter {
1790                            continue;
1791                        }
1792                    }
1793                    let def = defs.iter().find(|d| d.id == inst.definition_id);
1794                    let identity_name = if inst.identity_id.is_empty() {
1795                        "(ambient creds)".to_string()
1796                    } else {
1797                        identities
1798                            .iter()
1799                            .find(|i| i.id == inst.identity_id)
1800                            .map(|i| i.name.clone())
1801                            .unwrap_or_else(|| "(missing identity)".to_string())
1802                    };
1803                    let memory_name = if inst.memory_id.is_empty() {
1804                        "(vanilla CLI)".to_string()
1805                    } else {
1806                        memories
1807                            .iter()
1808                            .find(|m| m.id == inst.memory_id)
1809                            .map(|m| m.name.clone())
1810                            .unwrap_or_else(|| "(missing memory)".to_string())
1811                    };
1812
1813                    // Stat first (cheap) — gives us the modts for
1814                    // sorting. Only fetch the full content if the
1815                    // snapshot exists.
1816                    let (has_snapshot, last_active_at, preview, node_count) =
1817                        if inst.block_id.is_empty() {
1818                            (false, inst.started_at, String::new(), 0usize)
1819                        } else {
1820                            match filestore.stat(&inst.block_id, "output.state.json") {
1821                                Ok(Some(file)) => {
1822                                    let modts = if file.modts > 0 {
1823                                        file.modts
1824                                    } else {
1825                                        inst.started_at
1826                                    };
1827                                    let (preview, node_count) = read_session_preview(
1828                                        &filestore,
1829                                        &inst.block_id,
1830                                    );
1831                                    (true, modts, preview, node_count)
1832                                }
1833                                _ => (false, inst.started_at, String::new(), 0usize),
1834                            }
1835                        };
1836
1837                    rows.push(RecentSessionRow {
1838                        instance_id: inst.id,
1839                        instance_name: inst.instance_name,
1840                        definition_id: inst.definition_id.clone(),
1841                        definition_name: def
1842                            .map(|d| d.name.clone())
1843                            .unwrap_or_else(|| "(missing definition)".to_string()),
1844                        provider: def.map(|d| d.provider.clone()).unwrap_or_default(),
1845                        working_directory: inst.working_directory,
1846                        identity_id: inst.identity_id,
1847                        identity_name,
1848                        memory_id: inst.memory_id,
1849                        memory_name,
1850                        block_id_hint: inst.block_id,
1851                        // Surface the CLI-captured session id so the
1852                        // picker reattach can `--resume <sid>` on the
1853                        // FIRST turn of the new block. Without this
1854                        // the new subprocess starts a fresh session
1855                        // and the CLI re-injects the startup context.
1856                        session_id: inst.session_id,
1857                        preview,
1858                        node_count,
1859                        last_active_at,
1860                        has_snapshot,
1861                    });
1862                }
1863
1864                // Sort: rows with a snapshot first (descending by
1865                // modts), then no-snapshot rows by started_at desc.
1866                // This keeps live conversations at the top while
1867                // still surfacing legacy rows.
1868                rows.sort_by(|a, b| match (a.has_snapshot, b.has_snapshot) {
1869                    (true, true) | (false, false) => {
1870                        b.last_active_at.cmp(&a.last_active_at)
1871                    }
1872                    (true, false) => std::cmp::Ordering::Less,
1873                    (false, true) => std::cmp::Ordering::Greater,
1874                });
1875                rows.truncate(limit);
1876
1877                Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
1878            })
1879        }),
1880    );
1881
1882    // ---- Definition fork ----
1883
1884    let wstore = state.wstore.clone();
1885    let broker = state.broker.clone();
1886    engine.register_handler(
1887        COMMAND_FORK_AGENT_DEFINITION,
1888        Box::new(move |data, _ctx| {
1889            let wstore = wstore.clone();
1890            let broker = broker.clone();
1891            Box::pin(async move {
1892                let cmd: CommandForkAgentDefinitionData = serde_json::from_value(data)
1893                    .map_err(|e| format!("forkagentdefinition: {e}"))?;
1894
1895                // Find the source definition by id.
1896                let source = wstore
1897                    .agent_def_list()
1898                    .map_err(|e| format!("forkagentdefinition: {e}"))?
1899                    .into_iter()
1900                    .find(|a| a.id == cmd.source_id)
1901                    .ok_or_else(|| format!("forkagentdefinition: source not found: {}", cmd.source_id))?;
1902
1903                // Build a new definition that shares the source's content but
1904                // has a fresh id/slug and records the lineage. Seed-bit is
1905                // cleared — forks are always user-owned, not built-in.
1906                let now = SystemTime::now()
1907                    .duration_since(UNIX_EPOCH)
1908                    .map(|d| d.as_millis() as i64)
1909                    .unwrap_or(0);
1910                let branch_slug_part = if cmd.branch_label.is_empty() {
1911                    "fork".to_string()
1912                } else {
1913                    crate::backend::storage::wstore::derive_slug(&cmd.branch_label)
1914                };
1915                let mut fork = AgentDefinition {
1916                    id: uuid::Uuid::new_v4().to_string(),
1917                    // Empty slug → agent_def_insert derives + resolves collisions.
1918                    slug: format!("{}-{}", source.slug, branch_slug_part),
1919                    name: if cmd.branch_label.is_empty() {
1920                        format!("{} (fork)", source.name)
1921                    } else {
1922                        format!("{} [{}]", source.name, cmd.branch_label)
1923                    },
1924                    icon: source.icon.clone(),
1925                    provider: source.provider.clone(),
1926                    description: source.description.clone(),
1927                    working_directory: String::new(), // force re-resolve via agentmuxHome()
1928                    shell: source.shell.clone(),
1929                    provider_flags: source.provider_flags.clone(),
1930                    auto_start: 0, // forks don't auto-start; explicit launch only
1931                    restart_on_crash: source.restart_on_crash,
1932                    idle_timeout_minutes: source.idle_timeout_minutes,
1933                    created_at: now,
1934                    agent_type: source.agent_type.clone(),
1935                    environment: source.environment.clone(),
1936                    agent_bus_id: String::new(), // fresh bus id so broadcasts don't cross
1937                    is_seeded: 0,
1938                    accounts: String::new(),
1939                    parent_id: source.id.clone(),
1940                    branch_label: cmd.branch_label.clone(),
1941                    updated_at: now,
1942                    user_hidden: 0,
1943                };
1944                wstore
1945                    .agent_def_insert(&mut fork)
1946                    .map_err(|e| format!("forkagentdefinition: {e}"))?;
1947
1948                // Deep-copy content blobs + skills from source. Cascade foreign
1949                // keys on the source are unaffected — we're copying out, not
1950                // moving.
1951                let source_contents = wstore
1952                    .agent_content_get_all(&source.id)
1953                    .map_err(|e| format!("forkagentdefinition content: {e}"))?;
1954                for c in source_contents {
1955                    let new_content = AgentContent {
1956                        agent_id: fork.id.clone(),
1957                        content_type: c.content_type,
1958                        content: c.content,
1959                        updated_at: now,
1960                    };
1961                    wstore
1962                        .agent_content_set(&new_content)
1963                        .map_err(|e| format!("forkagentdefinition content: {e}"))?;
1964                }
1965                let source_skills = wstore
1966                    .agent_skill_list(&source.id)
1967                    .map_err(|e| format!("forkagentdefinition skills: {e}"))?;
1968                for s in source_skills {
1969                    let new_skill = AgentSkill {
1970                        id: uuid::Uuid::new_v4().to_string(),
1971                        agent_id: fork.id.clone(),
1972                        name: s.name,
1973                        trigger: s.trigger,
1974                        skill_type: s.skill_type,
1975                        description: s.description,
1976                        content: s.content,
1977                        created_at: now,
1978                    };
1979                    wstore
1980                        .agent_skill_insert(&new_skill)
1981                        .map_err(|e| format!("forkagentdefinition skill: {e}"))?;
1982                }
1983
1984                broker.publish(crate::backend::wps::WaveEvent {
1985                    event: "agents:changed".to_string(),
1986                    scopes: vec![],
1987                    sender: String::new(),
1988                    persist: 0,
1989                    data: None,
1990                });
1991
1992                Ok(Some(serde_json::to_value(&fork).unwrap_or_default()))
1993            })
1994        }),
1995    );
1996
1997    register_agent_session_handlers(engine, state);
1998    register_v7_handlers(engine, state);
1999}
2000
2001/// Option E (PR 1 of 2) — agent-anchored session zone RPCs.
2002///
2003/// These commands read/write the per-agent FileStore zone
2004/// `agent:<definition_id>:current` and the per-archive zones
2005/// `agent:<definition_id>:archive:<ts_ms>`. Session is bound to the
2006/// agent definition, NOT the identity bundle — see the spec.
2007fn register_agent_session_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
2008    // ---- agent:session:read ----
2009    let filestore = state.filestore.clone();
2010    engine.register_handler(
2011        COMMAND_AGENT_SESSION_READ,
2012        Box::new(move |data, _ctx| {
2013            let filestore = filestore.clone();
2014            Box::pin(async move {
2015                let cmd: CommandAgentSessionReadData = serde_json::from_value(data)
2016                    .map_err(|e| format!("agent:session:read: {e}"))?;
2017                let (content, modts) =
2018                    crate::backend::agent_session::read_session_state(&filestore, &cmd.definition_id)
2019                        .map_err(|e| format!("agent:session:read: {e}"))?;
2020                Ok(Some(
2021                    serde_json::to_value(&AgentSessionReadResult { content, modts })
2022                        .unwrap_or_default(),
2023                ))
2024            })
2025        }),
2026    );
2027
2028    // ---- agent:session:write_state ----
2029    let filestore = state.filestore.clone();
2030    engine.register_handler(
2031        COMMAND_AGENT_SESSION_WRITE_STATE,
2032        Box::new(move |data, _ctx| {
2033            let filestore = filestore.clone();
2034            Box::pin(async move {
2035                let cmd: CommandAgentSessionWriteStateData = serde_json::from_value(data)
2036                    .map_err(|e| format!("agent:session:write_state: {e}"))?;
2037                let bytes = cmd.content.as_bytes();
2038                let bytes_written = bytes.len() as u64;
2039                crate::backend::agent_session::write_session_state(
2040                    &filestore,
2041                    &cmd.definition_id,
2042                    bytes,
2043                )
2044                .map_err(|e| format!("agent:session:write_state: {e}"))?;
2045                Ok(Some(
2046                    serde_json::to_value(&AgentSessionWriteStateResult { bytes_written })
2047                        .unwrap_or_default(),
2048                ))
2049            })
2050        }),
2051    );
2052
2053    // ---- agent:session:append_output ----
2054    let filestore = state.filestore.clone();
2055    engine.register_handler(
2056        COMMAND_AGENT_SESSION_APPEND_OUTPUT,
2057        Box::new(move |data, _ctx| {
2058            let filestore = filestore.clone();
2059            Box::pin(async move {
2060                let cmd: CommandAgentSessionAppendOutputData = serde_json::from_value(data)
2061                    .map_err(|e| format!("agent:session:append_output: {e}"))?;
2062                let bytes_written = crate::backend::agent_session::append_session_output(
2063                    &filestore,
2064                    &cmd.definition_id,
2065                    &cmd.line,
2066                )
2067                .map_err(|e| format!("agent:session:append_output: {e}"))?;
2068                Ok(Some(
2069                    serde_json::to_value(&AgentSessionAppendOutputResult { bytes_written })
2070                        .unwrap_or_default(),
2071                ))
2072            })
2073        }),
2074    );
2075
2076    // ---- agent:session:archive ----
2077    let filestore = state.filestore.clone();
2078    engine.register_handler(
2079        COMMAND_AGENT_SESSION_ARCHIVE,
2080        Box::new(move |data, _ctx| {
2081            let filestore = filestore.clone();
2082            Box::pin(async move {
2083                let cmd: CommandAgentSessionArchiveData = serde_json::from_value(data)
2084                    .map_err(|e| format!("agent:session:archive: {e}"))?;
2085                let result =
2086                    crate::backend::agent_session::archive_session(&filestore, &cmd.definition_id)
2087                        .map_err(|e| format!("agent:session:archive: {e}"))?;
2088                let (archive_zoneid, archived_at_ms) = match result {
2089                    Some((z, ts)) => (z, ts),
2090                    None => (String::new(), 0),
2091                };
2092                Ok(Some(
2093                    serde_json::to_value(&AgentSessionArchiveResult {
2094                        archive_zoneid,
2095                        archived_at_ms,
2096                    })
2097                    .unwrap_or_default(),
2098                ))
2099            })
2100        }),
2101    );
2102
2103    // ---- agent:session:list_archives ----
2104    let filestore = state.filestore.clone();
2105    engine.register_handler(
2106        COMMAND_AGENT_SESSION_LIST_ARCHIVES,
2107        Box::new(move |data, _ctx| {
2108            let filestore = filestore.clone();
2109            Box::pin(async move {
2110                let cmd: CommandAgentSessionListArchivesData =
2111                    serde_json::from_value(data).unwrap_or_default();
2112                let summaries = crate::backend::agent_session::list_archives(
2113                    &filestore,
2114                    &cmd.definition_id,
2115                    cmd.limit,
2116                )
2117                .map_err(|e| format!("agent:session:list_archives: {e}"))?;
2118                let rows: Vec<AgentArchiveRow> = summaries
2119                    .into_iter()
2120                    .map(|s| AgentArchiveRow {
2121                        archive_zoneid: s.archive_zoneid,
2122                        archived_at_ms: s.archived_at_ms,
2123                        preview: s.preview,
2124                        node_count: s.node_count,
2125                    })
2126                    .collect();
2127                Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
2128            })
2129        }),
2130    );
2131}
2132
2133/// v7 handlers — Identity bundles (named credential bundles) + Memory bundles.
2134/// See `docs/specs/identity-forge-integration-and-vault-2026-05-08.md`.
2135///
2136/// Identity bundles aggregate accounts (one per provider) under a named
2137/// label, replacing the per-agent `db_agent_identity_links` semantics.
2138/// Memory bundles hold the agent's personality + capability stack.
2139fn register_v7_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
2140    // ---- Identity bundle CRUD ----
2141
2142    let wstore = state.wstore.clone();
2143    engine.register_handler(
2144        COMMAND_LIST_IDENTITY_BUNDLES,
2145        Box::new(move |_data, _ctx| {
2146            let wstore = wstore.clone();
2147            Box::pin(async move {
2148                let bundles = wstore
2149                    .bundle_identity_list()
2150                    .map_err(|e| format!("listidentitybundles: {e}"))?;
2151                Ok(Some(serde_json::to_value(&bundles).unwrap_or_default()))
2152            })
2153        }),
2154    );
2155
2156    let wstore = state.wstore.clone();
2157    engine.register_handler(
2158        COMMAND_GET_IDENTITY_BUNDLE,
2159        Box::new(move |data, _ctx| {
2160            let wstore = wstore.clone();
2161            Box::pin(async move {
2162                let cmd: CommandGetIdentityBundleData = serde_json::from_value(data)
2163                    .map_err(|e| format!("getidentitybundle: {e}"))?;
2164                match wstore
2165                    .bundle_identity_get(&cmd.id)
2166                    .map_err(|e| format!("getidentitybundle: {e}"))?
2167                {
2168                    Some(b) => Ok(Some(serde_json::to_value(&b).unwrap_or_default())),
2169                    None => Err(format!("getidentitybundle: not found id={}", cmd.id)),
2170                }
2171            })
2172        }),
2173    );
2174
2175    let wstore = state.wstore.clone();
2176    let broker = state.broker.clone();
2177    engine.register_handler(
2178        COMMAND_UPSERT_IDENTITY_BUNDLE,
2179        Box::new(move |data, _ctx| {
2180            let wstore = wstore.clone();
2181            let broker = broker.clone();
2182            Box::pin(async move {
2183                let mut bundle: Identity = serde_json::from_value(data)
2184                    .map_err(|e| format!("upsertidentitybundle: {e}"))?;
2185                // Guard on BOTH client-supplied is_blank AND id == "blank".
2186                // Without the id check a caller could send
2187                // {id:"blank", is_blank:false, name:"evil"} and the
2188                // ON CONFLICT(id) DO UPDATE path would rename/re-describe
2189                // the seeded singleton. (reagent P1, 2026-05-08).
2190                if bundle.is_blank || bundle.id == "blank" {
2191                    return Err(
2192                        "upsertidentitybundle: cannot mutate the blank singleton".to_string(),
2193                    );
2194                }
2195                if bundle.id.is_empty() {
2196                    bundle.id = uuid::Uuid::new_v4().to_string();
2197                }
2198                let now = SystemTime::now()
2199                    .duration_since(UNIX_EPOCH)
2200                    .map(|d| d.as_millis() as i64)
2201                    .unwrap_or(0);
2202                if bundle.created_at == 0 {
2203                    bundle.created_at = now;
2204                }
2205                bundle.updated_at = now;
2206                wstore
2207                    .bundle_identity_upsert(&bundle)
2208                    .map_err(|e| format!("upsertidentitybundle: {e}"))?;
2209                broker.publish(crate::backend::wps::WaveEvent {
2210                    event: "identitybundles:changed".to_string(),
2211                    scopes: vec![],
2212                    sender: String::new(),
2213                    persist: 0,
2214                    data: None,
2215                });
2216                Ok(Some(serde_json::to_value(&bundle).unwrap_or_default()))
2217            })
2218        }),
2219    );
2220
2221    let wstore = state.wstore.clone();
2222    let broker = state.broker.clone();
2223    engine.register_handler(
2224        COMMAND_DELETE_IDENTITY_BUNDLE,
2225        Box::new(move |data, _ctx| {
2226            let wstore = wstore.clone();
2227            let broker = broker.clone();
2228            Box::pin(async move {
2229                let cmd: CommandDeleteIdentityBundleData = serde_json::from_value(data)
2230                    .map_err(|e| format!("deleteidentitybundle: {e}"))?;
2231                let deleted = wstore
2232                    .bundle_identity_delete(&cmd.id)
2233                    .map_err(|e| format!("deleteidentitybundle: {e}"))?;
2234                if deleted {
2235                    broker.publish(crate::backend::wps::WaveEvent {
2236                        event: "identitybundles:changed".to_string(),
2237                        scopes: vec![],
2238                        sender: String::new(),
2239                        persist: 0,
2240                        data: None,
2241                    });
2242                }
2243                Ok(Some(json!({ "deleted": deleted })))
2244            })
2245        }),
2246    );
2247
2248    // ---- Identity bundle bindings (junction with accounts) ----
2249
2250    let wstore = state.wstore.clone();
2251    let broker = state.broker.clone();
2252    engine.register_handler(
2253        COMMAND_BIND_IDENTITY_ACCOUNT,
2254        Box::new(move |data, _ctx| {
2255            let wstore = wstore.clone();
2256            let broker = broker.clone();
2257            Box::pin(async move {
2258                let cmd: CommandBindIdentityAccountData = serde_json::from_value(data)
2259                    .map_err(|e| format!("bindidentityaccount: {e}"))?;
2260                wstore
2261                    .bundle_identity_bind(&cmd.identity_id, &cmd.provider, &cmd.account_id)
2262                    .map_err(|e| format!("bindidentityaccount: {e}"))?;
2263                broker.publish(crate::backend::wps::WaveEvent {
2264                    event: format!("identitybundlebindings:changed:{}", cmd.identity_id),
2265                    scopes: vec![],
2266                    sender: String::new(),
2267                    persist: 0,
2268                    data: None,
2269                });
2270                Ok(None)
2271            })
2272        }),
2273    );
2274
2275    let wstore = state.wstore.clone();
2276    let broker = state.broker.clone();
2277    engine.register_handler(
2278        COMMAND_UNBIND_IDENTITY_ACCOUNT,
2279        Box::new(move |data, _ctx| {
2280            let wstore = wstore.clone();
2281            let broker = broker.clone();
2282            Box::pin(async move {
2283                let cmd: CommandUnbindIdentityAccountData = serde_json::from_value(data)
2284                    .map_err(|e| format!("unbindidentityaccount: {e}"))?;
2285                let removed = wstore
2286                    .bundle_identity_unbind(&cmd.identity_id, &cmd.provider)
2287                    .map_err(|e| format!("unbindidentityaccount: {e}"))?;
2288                if removed {
2289                    broker.publish(crate::backend::wps::WaveEvent {
2290                        event: format!("identitybundlebindings:changed:{}", cmd.identity_id),
2291                        scopes: vec![],
2292                        sender: String::new(),
2293                        persist: 0,
2294                        data: None,
2295                    });
2296                }
2297                Ok(Some(json!({ "unbound": removed })))
2298            })
2299        }),
2300    );
2301
2302    let wstore = state.wstore.clone();
2303    engine.register_handler(
2304        COMMAND_LIST_IDENTITY_BINDINGS,
2305        Box::new(move |data, _ctx| {
2306            let wstore = wstore.clone();
2307            Box::pin(async move {
2308                let cmd: CommandListIdentityBindingsData = serde_json::from_value(data)
2309                    .map_err(|e| format!("listidentitybindings: {e}"))?;
2310                let bindings = wstore
2311                    .bundle_identity_bindings(&cmd.identity_id)
2312                    .map_err(|e| format!("listidentitybindings: {e}"))?;
2313                Ok(Some(serde_json::to_value(&bindings).unwrap_or_default()))
2314            })
2315        }),
2316    );
2317
2318    // ---- Memory bundle CRUD ----
2319
2320    let wstore = state.wstore.clone();
2321    engine.register_handler(
2322        COMMAND_LIST_MEMORIES,
2323        Box::new(move |_data, _ctx| {
2324            let wstore = wstore.clone();
2325            Box::pin(async move {
2326                let memories = wstore
2327                    .bundle_memory_list()
2328                    .map_err(|e| format!("listmemories: {e}"))?;
2329                Ok(Some(serde_json::to_value(&memories).unwrap_or_default()))
2330            })
2331        }),
2332    );
2333
2334    let wstore = state.wstore.clone();
2335    engine.register_handler(
2336        COMMAND_GET_MEMORY,
2337        Box::new(move |data, _ctx| {
2338            let wstore = wstore.clone();
2339            Box::pin(async move {
2340                let cmd: CommandGetMemoryData = serde_json::from_value(data)
2341                    .map_err(|e| format!("getmemory: {e}"))?;
2342                match wstore
2343                    .bundle_memory_get(&cmd.id)
2344                    .map_err(|e| format!("getmemory: {e}"))?
2345                {
2346                    Some(m) => Ok(Some(serde_json::to_value(&m).unwrap_or_default())),
2347                    None => Err(format!("getmemory: not found id={}", cmd.id)),
2348                }
2349            })
2350        }),
2351    );
2352
2353    let wstore = state.wstore.clone();
2354    let broker = state.broker.clone();
2355    engine.register_handler(
2356        COMMAND_UPSERT_MEMORY,
2357        Box::new(move |data, _ctx| {
2358            let wstore = wstore.clone();
2359            let broker = broker.clone();
2360            Box::pin(async move {
2361                let mut memory: Memory = serde_json::from_value(data)
2362                    .map_err(|e| format!("upsertmemory: {e}"))?;
2363                // Guard on BOTH client-supplied is_blank AND id == "blank".
2364                // Same bypass as upsertidentitybundle — see that comment.
2365                // (reagent P1, 2026-05-08).
2366                if memory.is_blank || memory.id == "blank" {
2367                    return Err("upsertmemory: cannot mutate the blank singleton".to_string());
2368                }
2369                if memory.id.is_empty() {
2370                    memory.id = uuid::Uuid::new_v4().to_string();
2371                }
2372                let now = SystemTime::now()
2373                    .duration_since(UNIX_EPOCH)
2374                    .map(|d| d.as_millis() as i64)
2375                    .unwrap_or(0);
2376                if memory.created_at == 0 {
2377                    memory.created_at = now;
2378                }
2379                memory.updated_at = now;
2380                wstore
2381                    .bundle_memory_upsert(&memory)
2382                    .map_err(|e| format!("upsertmemory: {e}"))?;
2383                broker.publish(crate::backend::wps::WaveEvent {
2384                    event: "memories:changed".to_string(),
2385                    scopes: vec![],
2386                    sender: String::new(),
2387                    persist: 0,
2388                    data: None,
2389                });
2390                Ok(Some(serde_json::to_value(&memory).unwrap_or_default()))
2391            })
2392        }),
2393    );
2394
2395    let wstore = state.wstore.clone();
2396    let broker = state.broker.clone();
2397    engine.register_handler(
2398        COMMAND_DELETE_MEMORY,
2399        Box::new(move |data, _ctx| {
2400            let wstore = wstore.clone();
2401            let broker = broker.clone();
2402            Box::pin(async move {
2403                let cmd: CommandDeleteMemoryData = serde_json::from_value(data)
2404                    .map_err(|e| format!("deletememory: {e}"))?;
2405                let deleted = wstore
2406                    .bundle_memory_delete(&cmd.id)
2407                    .map_err(|e| format!("deletememory: {e}"))?;
2408                if deleted {
2409                    broker.publish(crate::backend::wps::WaveEvent {
2410                        event: "memories:changed".to_string(),
2411                        scopes: vec![],
2412                        sender: String::new(),
2413                        persist: 0,
2414                        data: None,
2415                    });
2416                }
2417                Ok(Some(json!({ "deleted": deleted })))
2418            })
2419        }),
2420    );
2421}
2422
2423/// Read the per-block `output.state.json` snapshot from filestore and
2424/// extract a `(preview, node_count)` pair for the AgentPicker's
2425/// "Recent sessions" list.
2426///
2427/// The snapshot shape is owned by the frontend (see
2428/// `frontend/app/view/agent/agent-view.tsx::writeSnapshotNow`):
2429/// `{ schemaVersion, savedAt, highWaterMark, historyOffset, nodes: [DocumentNode...] }`.
2430/// We only touch two fields:
2431/// - `nodes.length` → `node_count`.
2432/// - The first node with `type === "user_message"`, `message` field →
2433///   `preview` (trimmed, newlines collapsed, max 240 chars).
2434///
2435/// On any error (snapshot missing, malformed JSON, no user message),
2436/// returns `("", 0)`. Callers treat that the same as "no preview".
2437fn read_session_preview(
2438    filestore: &crate::backend::storage::filestore::FileStore,
2439    block_id: &str,
2440) -> (String, usize) {
2441    let bytes = match filestore.read_file(block_id, "output.state.json") {
2442        Ok(Some(b)) => b,
2443        _ => return (String::new(), 0),
2444    };
2445    // Cap the parse budget — a misbehaving / corrupted snapshot
2446    // shouldn't be able to stall this handler. 4MiB is well above the
2447    // typical conversation snapshot (Maks's was ~750KiB for 169 nodes)
2448    // but bounded enough to fail fast on garbage.
2449    if bytes.len() > 4 * 1024 * 1024 {
2450        tracing::warn!(
2451            block_id = %block_id,
2452            size = bytes.len(),
2453            "listrecentsessions: snapshot too large; skipping preview"
2454        );
2455        return (String::new(), 0);
2456    }
2457    let json: serde_json::Value = match serde_json::from_slice(&bytes) {
2458        Ok(v) => v,
2459        Err(_) => return (String::new(), 0),
2460    };
2461    let nodes = match json.get("nodes").and_then(|v| v.as_array()) {
2462        Some(a) => a,
2463        None => return (String::new(), 0),
2464    };
2465    let node_count = nodes.len();
2466    // First user_message wins. Skip the bootstrap "Session Context"
2467    // prompt when present — it's always the first node and is system
2468    // boilerplate the user didn't type; if a subsequent user_message
2469    // exists, that's the more useful preview. Heuristic: if the first
2470    // user message starts with "# Session Context", scan for the next.
2471    let mut preview = String::new();
2472    for node in nodes {
2473        let ty = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
2474        if ty != "user_message" {
2475            continue;
2476        }
2477        let msg = node
2478            .get("message")
2479            .and_then(|v| v.as_str())
2480            .unwrap_or("")
2481            .trim();
2482        if msg.is_empty() {
2483            continue;
2484        }
2485        if preview.is_empty() && msg.starts_with("# Session Context") {
2486            // Stash as fallback in case there's no later user_message.
2487            preview = collapse_preview(msg);
2488            continue;
2489        }
2490        preview = collapse_preview(msg);
2491        break;
2492    }
2493    (preview, node_count)
2494}
2495
2496/// Collapse newlines + extra whitespace, cap at 240 chars. Output is
2497/// safe to render inline in a single-line preview row.
2498fn collapse_preview(s: &str) -> String {
2499    const MAX_CHARS: usize = 240;
2500    let mut buf = String::with_capacity(s.len().min(MAX_CHARS + 4));
2501    let mut prev_space = false;
2502    for ch in s.chars() {
2503        if buf.chars().count() >= MAX_CHARS {
2504            buf.push('\u{2026}'); // "…"
2505            return buf;
2506        }
2507        if ch.is_whitespace() {
2508            if !prev_space && !buf.is_empty() {
2509                buf.push(' ');
2510                prev_space = true;
2511            }
2512        } else {
2513            buf.push(ch);
2514            prev_space = false;
2515        }
2516    }
2517    buf
2518}
2519
2520#[cfg(test)]
2521mod recent_sessions_tests {
2522    use super::*;
2523    use crate::backend::storage::filestore::FileStore;
2524
2525    fn fresh_filestore() -> std::sync::Arc<FileStore> {
2526        std::sync::Arc::new(FileStore::open_in_memory().unwrap())
2527    }
2528
2529    fn write_snapshot(fs: &FileStore, block_id: &str, body: &str) {
2530        // make_file then write_file mirrors the production
2531        // BlockfileWriteState handler path.
2532        let meta: crate::backend::storage::filestore::FileMeta =
2533            std::collections::HashMap::new();
2534        let opts = crate::backend::storage::filestore::FileOpts::default();
2535        fs.make_file(block_id, "output.state.json", meta, opts)
2536            .expect("make_file");
2537        fs.write_file(block_id, "output.state.json", body.as_bytes())
2538            .expect("write_file");
2539    }
2540
2541    #[test]
2542    fn collapse_preview_strips_newlines_and_caps_length() {
2543        let s = "hello\n\nworld\n  next   line";
2544        assert_eq!(collapse_preview(s), "hello world next line");
2545        let long: String = "a".repeat(500);
2546        let out = collapse_preview(&long);
2547        // 240 chars + ellipsis.
2548        assert!(out.ends_with('\u{2026}'));
2549        assert!(out.chars().count() <= 241);
2550    }
2551
2552    #[test]
2553    fn read_session_preview_missing_returns_zero() {
2554        let fs = fresh_filestore();
2555        let (preview, count) = read_session_preview(&fs, "no-such-block");
2556        assert_eq!(preview, "");
2557        assert_eq!(count, 0);
2558    }
2559
2560    #[test]
2561    fn read_session_preview_extracts_first_user_message_skipping_context() {
2562        let fs = fresh_filestore();
2563        // Two user messages: first is the boilerplate Session Context;
2564        // second is the user's real prompt. Preview should be the real one.
2565        let snapshot = serde_json::json!({
2566            "schemaVersion": 1,
2567            "savedAt": "2026-05-23T08:00:00Z",
2568            "highWaterMark": 169,
2569            "historyOffset": 0,
2570            "nodes": [
2571                {
2572                    "type": "user_message",
2573                    "id": "u0",
2574                    "timestamp": 0,
2575                    "collapsed": false,
2576                    "summary": "👤 User Message",
2577                    "message": "# Session Context\nIdentity: Claude\n## Description\nStartup boilerplate"
2578                },
2579                { "type": "markdown", "id": "m0", "content": "ack" },
2580                {
2581                    "type": "user_message",
2582                    "id": "u1",
2583                    "timestamp": 100,
2584                    "collapsed": false,
2585                    "summary": "👤 User Message",
2586                    "message": "check the agentmuxai/agentmux history, get the latest code"
2587                }
2588            ]
2589        });
2590        write_snapshot(&fs, "blk-1", &snapshot.to_string());
2591        let (preview, count) = read_session_preview(&fs, "blk-1");
2592        assert_eq!(count, 3);
2593        assert!(preview.starts_with("check the agentmuxai/agentmux"));
2594    }
2595
2596    #[test]
2597    fn read_session_preview_falls_back_to_session_context_when_only_one() {
2598        let fs = fresh_filestore();
2599        let snapshot = serde_json::json!({
2600            "schemaVersion": 1,
2601            "nodes": [
2602                {
2603                    "type": "user_message",
2604                    "id": "u0",
2605                    "message": "# Session Context\nIdentity: Claude\nStartup boilerplate"
2606                }
2607            ]
2608        });
2609        write_snapshot(&fs, "blk-2", &snapshot.to_string());
2610        let (preview, count) = read_session_preview(&fs, "blk-2");
2611        assert_eq!(count, 1);
2612        // Newlines collapsed; starts with the boilerplate marker.
2613        assert!(preview.starts_with("# Session Context"));
2614    }
2615
2616    #[test]
2617    fn read_session_preview_handles_malformed_json() {
2618        let fs = fresh_filestore();
2619        write_snapshot(&fs, "blk-3", "not valid json {");
2620        let (preview, count) = read_session_preview(&fs, "blk-3");
2621        assert_eq!(preview, "");
2622        assert_eq!(count, 0);
2623    }
2624
2625    #[test]
2626    fn read_session_preview_handles_no_user_messages() {
2627        let fs = fresh_filestore();
2628        let snapshot = serde_json::json!({
2629            "schemaVersion": 1,
2630            "nodes": [
2631                { "type": "markdown", "id": "m0", "content": "system note" }
2632            ]
2633        });
2634        write_snapshot(&fs, "blk-4", &snapshot.to_string());
2635        let (preview, count) = read_session_preview(&fs, "blk-4");
2636        assert_eq!(preview, "");
2637        assert_eq!(count, 1);
2638    }
2639
2640    // ── Integration test: full listrecentsessions handler ────────────
2641    //
2642    // Spins up the same engine + state shape as the production
2643    // websocket path so the handler runs end-to-end against an
2644    // in-memory wstore + filestore. Asserts the row shape, the
2645    // identity filter, the snapshot-first sort, the preview extraction,
2646    // and the cross-version "no snapshot" fallback. This is the
2647    // backend correctness gate for the AgentPicker's Recent Sessions
2648    // surface (cascade follow-up 2026-05-23).
2649    use crate::backend::storage::wstore::{
2650        AgentDefinition, AgentInstance, Identity, InstanceStatus, Memory, WaveStore,
2651    };
2652    use crate::backend::rpc::engine::WshRpcEngine;
2653    use crate::server::AppState;
2654    use std::sync::Arc;
2655
2656    /// Drive a single RPC round-trip against the in-memory engine,
2657    /// asserting success + deserializing the JSON payload into `T`.
2658    async fn call_rpc<T: serde::de::DeserializeOwned>(
2659        engine: &Arc<WshRpcEngine>,
2660        rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
2661        command: &str,
2662        data: serde_json::Value,
2663    ) -> T {
2664        let req_id = format!("test-{}", uuid::Uuid::new_v4());
2665        let msg = crate::backend::rpc_types::RpcMessage {
2666            command: command.to_string(),
2667            reqid: req_id.clone(),
2668            data: Some(data),
2669            ..Default::default()
2670        };
2671        engine.handle_message(msg);
2672        let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
2673            .await
2674            .expect("handler timed out")
2675            .expect("output channel closed");
2676        assert_eq!(resp.resid, req_id, "unexpected response id");
2677        assert!(resp.error.is_empty(), "handler returned error: {}", resp.error);
2678        let payload = resp.data.unwrap_or(serde_json::Value::Null);
2679        serde_json::from_value(payload).expect("response deserialize")
2680    }
2681
2682    fn build_state_with_seed() -> (
2683        AppState,
2684        Arc<WshRpcEngine>,
2685        tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
2686    ) {
2687        let wstore = Arc::new(WaveStore::open_in_memory().unwrap());
2688        let filestore = Arc::new(FileStore::open_in_memory().unwrap());
2689        let event_bus = Arc::new(crate::backend::eventbus::EventBus::new());
2690        let broker = Arc::new(crate::backend::wps::Broker::new());
2691        let reactive_handler = crate::backend::reactive::get_global_handler();
2692        let poller = Arc::new(crate::backend::reactive::Poller::new(
2693            crate::backend::reactive::PollerConfig {
2694                agentmux_url: None,
2695                agentmux_token: None,
2696                poll_interval_secs: 30,
2697            },
2698            reactive_handler,
2699        ));
2700        crate::backend::wcore::ensure_initial_data(&wstore).unwrap();
2701        let config_watcher = Arc::new(crate::backend::wconfig::ConfigWatcher::new());
2702        let process_tracker = Arc::new(
2703            crate::backend::process_tracker::registry::AgentProcessRegistry::new(Some(broker.clone())),
2704        );
2705        let state = AppState {
2706            auth_key: "test".to_string(),
2707            version: "test".to_string(),
2708            app_path: String::new(),
2709            wstore: wstore.clone(),
2710            filestore: filestore.clone(),
2711            event_bus: event_bus.clone(),
2712            broker,
2713            reactive_handler,
2714            poller,
2715            config_watcher,
2716            messagebus: Arc::new(crate::backend::messagebus::MessageBus::new()),
2717            http_client: reqwest::Client::new(),
2718            local_web_url: String::new(),
2719            subagent_watcher: Arc::new(crate::backend::subagent_watcher::SubagentWatcher::new(event_bus)),
2720            history_service: Arc::new(crate::backend::history::HistoryService::new()),
2721            lan_discovery: None,
2722            process_tracker,
2723            srv_state: Arc::new(tokio::sync::Mutex::new(crate::state::State::default())),
2724            srv_events_tx: tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(64).0,
2725            saga_id_alloc: Arc::new(std::sync::atomic::AtomicU64::new(0)),
2726            saga_log: Arc::new(crate::sagas::log::SagaLog::open_in_memory().unwrap()),
2727            auth_session_manager: Arc::new(crate::identity::auth_session::AuthSessionManager::new()),
2728            install_sessions: crate::server::install_handlers::InstallSessionRegistry::new(),
2729        };
2730
2731        // Seed: 1 SEEDED definition (template), 1 identity bundle, 1
2732        // memory bundle. Phase 3b note: seeded as a template so that
2733        // each instance projection in `db_agents` lands on its own row
2734        // (`is_template = 0`, `id = inst.id`, `parent_template_id =
2735        // def.id`) rather than folding into the def-projection and
2736        // clobbering its name. The handler resolves `definition_name`
2737        // via `defs.iter().find(|d| d.id == inst.definition_id)`, which
2738        // hits the template row and returns "Claude Code". Under the
2739        // pre-Phase 3b reader, def name was always preserved because
2740        // `agent_def_list` queried `db_agent_definitions` directly;
2741        // db_agents fold semantics require the seed shape to avoid
2742        // the collision.
2743        let def = AgentDefinition {
2744            id: "def-claude".to_string(),
2745            slug: "claude-code".to_string(),
2746            name: "Claude Code".to_string(),
2747            icon: String::new(),
2748            provider: "claude".to_string(),
2749            description: String::new(),
2750            working_directory: String::new(),
2751            shell: String::new(),
2752            provider_flags: String::new(),
2753            auto_start: 0,
2754            restart_on_crash: 0,
2755            idle_timeout_minutes: 0,
2756            created_at: 0,
2757            agent_type: "host".to_string(),
2758            environment: String::new(),
2759            agent_bus_id: String::new(),
2760            is_seeded: 1,
2761            accounts: String::new(),
2762            parent_id: String::new(),
2763            branch_label: String::new(),
2764            updated_at: 0,
2765            user_hidden: 0,
2766        };
2767        let mut def_mut = def.clone();
2768        wstore.agent_def_insert(&mut def_mut).unwrap();
2769        let identity = Identity {
2770            id: "id-work".to_string(),
2771            name: "Work".to_string(),
2772            description: String::new(),
2773            is_blank: false,
2774            created_at: 0,
2775            updated_at: 0,
2776        };
2777        wstore.bundle_identity_upsert(&identity).unwrap();
2778        let memory = Memory {
2779            id: "mem-notes".to_string(),
2780            name: "Notes".to_string(),
2781            description: String::new(),
2782            is_blank: false,
2783            provider: String::new(),
2784            model: String::new(),
2785            instructions: String::new(),
2786            context_files: "[]".to_string(),
2787            mcp_servers: "[]".to_string(),
2788            skills: "[]".to_string(),
2789            created_at: 0,
2790            updated_at: 0,
2791        };
2792        wstore.bundle_memory_upsert(&memory).unwrap();
2793
2794        // 3 instances:
2795        //   - blk-recent: has snapshot, more recent activity
2796        //   - blk-older:  has snapshot, older activity
2797        //   - blk-none:   no snapshot at all (legacy / pre-persistence row)
2798        // All three use the same identity bundle so the filter test
2799        // can also exercise it without re-seeding.
2800        for (id, block, started) in [
2801            ("inst-recent", "blk-recent", 1_700_000_100_000_i64),
2802            ("inst-older", "blk-older", 1_700_000_000_000_i64),
2803            ("inst-none", "blk-none", 1_700_000_050_000_i64),
2804        ] {
2805            let inst = AgentInstance {
2806                id: id.to_string(),
2807                definition_id: "def-claude".to_string(),
2808                parent_instance_id: String::new(),
2809                block_id: block.to_string(),
2810                session_id: String::new(),
2811                status: InstanceStatus::Running.as_str().to_string(),
2812                github_context: String::new(),
2813                started_at: started,
2814                ended_at: 0,
2815                created_at: started,
2816                identity_id: "id-work".to_string(),
2817                memory_id: "mem-notes".to_string(),
2818                instance_name: format!("name-{id}"),
2819                working_directory: format!("/tmp/{id}"),
2820                display_hidden: false,
2821            };
2822            wstore.instance_create(&inst).unwrap();
2823        }
2824
2825        // Snapshots for the two with snapshots. Write the OLDER one
2826        // first so its filestore-stamped modts is strictly less than the
2827        // recent one — the handler sorts snapshot-bearing rows by modts
2828        // desc, so writing blk-older second would invert the assertions.
2829        // (Pre-Phase 3b this ordering was fragile because the dual-write
2830        // chain ran fewer SQL statements between successive inserts, so
2831        // adjacent writes landed in the same millisecond and the stable
2832        // sort preserved instance_list_named's started_at order; now the
2833        // additional db_agents UPDATE per instance widens the gap and
2834        // distinct modts dominate the stable sort.)
2835        let snap_older = serde_json::json!({
2836            "schemaVersion": 1,
2837            "nodes": [
2838                {"type": "user_message", "id": "u0",
2839                 "message": "earlier conversation"}
2840            ]
2841        });
2842        write_snapshot(&filestore, "blk-older", &snap_older.to_string());
2843        let snap_recent = serde_json::json!({
2844            "schemaVersion": 1,
2845            "nodes": [
2846                {"type": "user_message", "id": "u0",
2847                 "message": "# Session Context\nboilerplate"},
2848                {"type": "markdown", "id": "m0", "content": "ack"},
2849                {"type": "user_message", "id": "u1",
2850                 "message": "fix the live-feed hover delay"}
2851            ]
2852        });
2853        write_snapshot(&filestore, "blk-recent", &snap_recent.to_string());
2854
2855        let (engine, rx) = WshRpcEngine::new();
2856        super::register_agent_handlers(&engine, &state);
2857        (state, engine, rx)
2858    }
2859
2860    #[tokio::test]
2861    async fn handler_returns_sessions_with_previews_sorted_by_snapshot_first() {
2862        let (_state, engine, mut rx) = build_state_with_seed();
2863        let rows: Vec<RecentSessionRow> = call_rpc(
2864            &engine,
2865            &mut rx,
2866            COMMAND_LIST_RECENT_SESSIONS,
2867            serde_json::json!({}),
2868        )
2869        .await;
2870        assert_eq!(rows.len(), 3, "all three sessions surfaced");
2871
2872        // Sort: snapshot-bearing rows first (recent then older), then
2873        // the no-snapshot row at the tail.
2874        assert_eq!(rows[0].instance_id, "inst-recent");
2875        assert!(rows[0].has_snapshot);
2876        assert_eq!(rows[0].node_count, 3);
2877        assert!(
2878            rows[0].preview.starts_with("fix the live-feed"),
2879            "preview should be the post-context user message, got {:?}",
2880            rows[0].preview
2881        );
2882
2883        assert_eq!(rows[1].instance_id, "inst-older");
2884        assert!(rows[1].has_snapshot);
2885        assert_eq!(rows[1].node_count, 1);
2886        assert_eq!(rows[1].preview, "earlier conversation");
2887
2888        assert_eq!(rows[2].instance_id, "inst-none");
2889        assert!(!rows[2].has_snapshot);
2890        assert_eq!(rows[2].node_count, 0);
2891        assert_eq!(rows[2].preview, "");
2892
2893        // Joins: definition + identity + memory names resolved.
2894        assert_eq!(rows[0].definition_name, "Claude Code");
2895        assert_eq!(rows[0].identity_name, "Work");
2896        assert_eq!(rows[0].memory_name, "Notes");
2897        assert_eq!(rows[0].block_id_hint, "blk-recent");
2898    }
2899
2900    #[tokio::test]
2901    async fn handler_identity_filter_restricts_rows() {
2902        let (_state, engine, mut rx) = build_state_with_seed();
2903        // Filter to a non-existent identity → empty list.
2904        let rows: Vec<RecentSessionRow> = call_rpc(
2905            &engine,
2906            &mut rx,
2907            COMMAND_LIST_RECENT_SESSIONS,
2908            serde_json::json!({ "identity_id": "no-such-bundle" }),
2909        )
2910        .await;
2911        assert_eq!(rows.len(), 0);
2912
2913        // Filter to the seeded one → all three.
2914        let rows: Vec<RecentSessionRow> = call_rpc(
2915            &engine,
2916            &mut rx,
2917            COMMAND_LIST_RECENT_SESSIONS,
2918            serde_json::json!({ "identity_id": "id-work" }),
2919        )
2920        .await;
2921        assert_eq!(rows.len(), 3);
2922
2923        // Empty-string identity_id is treated as "no filter" so the
2924        // frontend can pass `""` without special-casing.
2925        let rows: Vec<RecentSessionRow> = call_rpc(
2926            &engine,
2927            &mut rx,
2928            COMMAND_LIST_RECENT_SESSIONS,
2929            serde_json::json!({ "identity_id": "" }),
2930        )
2931        .await;
2932        assert_eq!(rows.len(), 3);
2933    }
2934
2935    #[tokio::test]
2936    async fn handler_respects_limit() {
2937        let (_state, engine, mut rx) = build_state_with_seed();
2938        let rows: Vec<RecentSessionRow> = call_rpc(
2939            &engine,
2940            &mut rx,
2941            COMMAND_LIST_RECENT_SESSIONS,
2942            serde_json::json!({ "limit": 1 }),
2943        )
2944        .await;
2945        assert_eq!(rows.len(), 1);
2946        assert_eq!(rows[0].instance_id, "inst-recent");
2947    }
2948
2949    // ---- Two-tier picker Phase 1: create-from-template + listagents filter ----
2950    //
2951    // SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md.
2952
2953    /// Same shape as build_state_with_seed but with a seeded template
2954    /// and no instances, so the create-from-template path is exercised
2955    /// against a known-good template row.
2956    fn build_state_with_template_seed() -> (
2957        AppState,
2958        Arc<WshRpcEngine>,
2959        tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
2960    ) {
2961        let wstore = Arc::new(WaveStore::open_in_memory().unwrap());
2962        let filestore = Arc::new(FileStore::open_in_memory().unwrap());
2963        let event_bus = Arc::new(crate::backend::eventbus::EventBus::new());
2964        let broker = Arc::new(crate::backend::wps::Broker::new());
2965        let reactive_handler = crate::backend::reactive::get_global_handler();
2966        let poller = Arc::new(crate::backend::reactive::Poller::new(
2967            crate::backend::reactive::PollerConfig {
2968                agentmux_url: None,
2969                agentmux_token: None,
2970                poll_interval_secs: 30,
2971            },
2972            reactive_handler,
2973        ));
2974        crate::backend::wcore::ensure_initial_data(&wstore).unwrap();
2975        let config_watcher = Arc::new(crate::backend::wconfig::ConfigWatcher::new());
2976        let process_tracker = Arc::new(
2977            crate::backend::process_tracker::registry::AgentProcessRegistry::new(Some(broker.clone())),
2978        );
2979        let state = AppState {
2980            auth_key: "test".to_string(),
2981            version: "test".to_string(),
2982            app_path: String::new(),
2983            wstore: wstore.clone(),
2984            filestore: filestore.clone(),
2985            event_bus: event_bus.clone(),
2986            broker,
2987            reactive_handler,
2988            poller,
2989            config_watcher,
2990            messagebus: Arc::new(crate::backend::messagebus::MessageBus::new()),
2991            http_client: reqwest::Client::new(),
2992            local_web_url: String::new(),
2993            subagent_watcher: Arc::new(crate::backend::subagent_watcher::SubagentWatcher::new(event_bus)),
2994            history_service: Arc::new(crate::backend::history::HistoryService::new()),
2995            lan_discovery: None,
2996            process_tracker,
2997            srv_state: Arc::new(tokio::sync::Mutex::new(crate::state::State::default())),
2998            srv_events_tx: tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(64).0,
2999            saga_id_alloc: Arc::new(std::sync::atomic::AtomicU64::new(0)),
3000            saga_log: Arc::new(crate::sagas::log::SagaLog::open_in_memory().unwrap()),
3001            auth_session_manager: Arc::new(crate::identity::auth_session::AuthSessionManager::new()),
3002            install_sessions: crate::server::install_handlers::InstallSessionRegistry::new(),
3003        };
3004
3005        // One seeded template + one already-user-owned definition.
3006        let mut tpl = AgentDefinition {
3007            id: "tpl-claude".to_string(),
3008            slug: String::new(),
3009            name: "Claude Code".to_string(),
3010            icon: String::new(),
3011            provider: "claude".to_string(),
3012            description: "Anthropic's coding agent".to_string(),
3013            working_directory: String::new(),
3014            shell: String::new(),
3015            provider_flags: "--model haiku".to_string(),
3016            auto_start: 0,
3017            restart_on_crash: 0,
3018            idle_timeout_minutes: 0,
3019            created_at: 1_700_000_000_000,
3020            agent_type: "host".to_string(),
3021            environment: String::new(),
3022            agent_bus_id: String::new(),
3023            is_seeded: 1,
3024            accounts: String::new(),
3025            parent_id: String::new(),
3026            branch_label: String::new(),
3027            updated_at: 1_700_000_000_000,
3028            user_hidden: 0,
3029        };
3030        wstore.agent_def_insert(&mut tpl).unwrap();
3031
3032        let mut user_a = AgentDefinition {
3033            id: "user-a".to_string(),
3034            slug: String::new(),
3035            name: "Maks".to_string(),
3036            icon: String::new(),
3037            provider: "claude".to_string(),
3038            description: String::new(),
3039            working_directory: String::new(),
3040            shell: String::new(),
3041            provider_flags: String::new(),
3042            auto_start: 0,
3043            restart_on_crash: 0,
3044            idle_timeout_minutes: 0,
3045            created_at: 1_700_000_001_000,
3046            agent_type: "host".to_string(),
3047            environment: String::new(),
3048            agent_bus_id: String::new(),
3049            is_seeded: 0,
3050            accounts: String::new(),
3051            parent_id: String::new(),
3052            branch_label: String::new(),
3053            updated_at: 1_700_000_001_000,
3054            user_hidden: 0,
3055        };
3056        wstore.agent_def_insert(&mut user_a).unwrap();
3057
3058        let (engine, rx) = WshRpcEngine::new();
3059        super::register_agent_handlers(&engine, &state);
3060        (state, engine, rx)
3061    }
3062
3063    #[tokio::test]
3064    async fn listagents_no_filter_returns_all() {
3065        let (_state, engine, mut rx) = build_state_with_template_seed();
3066        let agents: Vec<AgentDefinition> = call_rpc(
3067            &engine,
3068            &mut rx,
3069            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3070            serde_json::json!({}),
3071        )
3072        .await;
3073        assert!(agents.iter().any(|a| a.id == "tpl-claude"));
3074        assert!(agents.iter().any(|a| a.id == "user-a"));
3075    }
3076
3077    #[tokio::test]
3078    async fn listagents_filter_templates_only() {
3079        let (_state, engine, mut rx) = build_state_with_template_seed();
3080        let agents: Vec<AgentDefinition> = call_rpc(
3081            &engine,
3082            &mut rx,
3083            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3084            serde_json::json!({ "is_seeded": 1 }),
3085        )
3086        .await;
3087        assert!(agents.iter().all(|a| a.is_seeded == 1));
3088        assert!(agents.iter().any(|a| a.id == "tpl-claude"));
3089        assert!(!agents.iter().any(|a| a.id == "user-a"));
3090    }
3091
3092    #[tokio::test]
3093    async fn listagents_filter_user_owned_only() {
3094        let (_state, engine, mut rx) = build_state_with_template_seed();
3095        let agents: Vec<AgentDefinition> = call_rpc(
3096            &engine,
3097            &mut rx,
3098            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3099            serde_json::json!({ "is_seeded": 0 }),
3100        )
3101        .await;
3102        assert!(agents.iter().all(|a| a.is_seeded == 0));
3103        assert!(agents.iter().any(|a| a.id == "user-a"));
3104        assert!(!agents.iter().any(|a| a.id == "tpl-claude"));
3105    }
3106
3107    #[tokio::test]
3108    async fn create_from_template_happy_path_clones_and_returns_id() {
3109        let (_state, engine, mut rx) = build_state_with_template_seed();
3110        let resp: crate::backend::rpc_types::AgentDefCreateFromTemplateResult = call_rpc(
3111            &engine,
3112            &mut rx,
3113            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
3114            serde_json::json!({
3115                "template_id": "tpl-claude",
3116                "name": "Asaf",
3117                "identity_id": "id-work",
3118                "memory_id": "mem-notes",
3119            }),
3120        )
3121        .await;
3122        assert!(!resp.definition_id.is_empty());
3123        assert_eq!(resp.identity_id, "id-work");
3124        assert_eq!(resp.memory_id, "mem-notes");
3125
3126        // The new row is user-owned, carries provider + flags from template.
3127        let agents: Vec<AgentDefinition> = call_rpc(
3128            &engine,
3129            &mut rx,
3130            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3131            serde_json::json!({}),
3132        )
3133        .await;
3134        let new_def = agents
3135            .iter()
3136            .find(|a| a.id == resp.definition_id)
3137            .expect("new definition should appear in listagents");
3138        assert_eq!(new_def.is_seeded, 0);
3139        assert_eq!(new_def.name, "Asaf");
3140        assert_eq!(new_def.provider, "claude");
3141        assert_eq!(new_def.provider_flags, "--model haiku");
3142        assert_eq!(new_def.parent_id, "tpl-claude");
3143    }
3144
3145    async fn call_rpc_expect_error(
3146        engine: &Arc<WshRpcEngine>,
3147        rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
3148        command: &str,
3149        data: serde_json::Value,
3150    ) -> String {
3151        let req_id = format!("test-{}", uuid::Uuid::new_v4());
3152        let msg = crate::backend::rpc_types::RpcMessage {
3153            command: command.to_string(),
3154            reqid: req_id.clone(),
3155            data: Some(data),
3156            ..Default::default()
3157        };
3158        engine.handle_message(msg);
3159        let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
3160            .await
3161            .expect("handler timed out")
3162            .expect("output channel closed");
3163        assert_eq!(resp.resid, req_id);
3164        assert!(
3165            !resp.error.is_empty(),
3166            "expected error, got success payload: {:?}",
3167            resp.data
3168        );
3169        resp.error
3170    }
3171
3172    #[tokio::test]
3173    async fn create_from_template_rejects_non_template_id() {
3174        let (_state, engine, mut rx) = build_state_with_template_seed();
3175        // "user-a" is is_seeded=0 — not a template.
3176        let err = call_rpc_expect_error(
3177            &engine,
3178            &mut rx,
3179            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
3180            serde_json::json!({
3181                "template_id": "user-a",
3182                "name": "another",
3183            }),
3184        )
3185        .await;
3186        assert!(
3187            err.contains("not a seeded template"),
3188            "wrong error: {err}"
3189        );
3190    }
3191
3192    #[tokio::test]
3193    async fn create_from_template_rejects_unknown_template_id() {
3194        let (_state, engine, mut rx) = build_state_with_template_seed();
3195        let err = call_rpc_expect_error(
3196            &engine,
3197            &mut rx,
3198            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
3199            serde_json::json!({
3200                "template_id": "no-such-id",
3201                "name": "x",
3202            }),
3203        )
3204        .await;
3205        assert!(err.contains("not found"), "wrong error: {err}");
3206    }
3207
3208    #[tokio::test]
3209    async fn create_from_template_rejects_duplicate_user_name() {
3210        let (_state, engine, mut rx) = build_state_with_template_seed();
3211        // "Maks" already exists as a user-owned agent.
3212        let err = call_rpc_expect_error(
3213            &engine,
3214            &mut rx,
3215            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
3216            serde_json::json!({
3217                "template_id": "tpl-claude",
3218                "name": "Maks",
3219            }),
3220        )
3221        .await;
3222        assert!(
3223            err.contains("already exists"),
3224            "wrong error: {err}"
3225        );
3226    }
3227
3228    #[tokio::test]
3229    async fn create_from_template_rejects_empty_name() {
3230        let (_state, engine, mut rx) = build_state_with_template_seed();
3231        let err = call_rpc_expect_error(
3232            &engine,
3233            &mut rx,
3234            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
3235            serde_json::json!({
3236                "template_id": "tpl-claude",
3237                "name": "   ",
3238            }),
3239        )
3240        .await;
3241        assert!(err.contains("non-empty"), "wrong error: {err}");
3242    }
3243
3244    // ---- Two-tier picker Phase 2: hide / unhide templates ----
3245    //
3246    // SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md Q2 Decision Y.
3247
3248    #[tokio::test]
3249    async fn hide_template_then_listagents_excludes_it_by_default() {
3250        let (_state, engine, mut rx) = build_state_with_template_seed();
3251
3252        // Before hide: template is in the default listagents result.
3253        let before: Vec<AgentDefinition> = call_rpc(
3254            &engine,
3255            &mut rx,
3256            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3257            serde_json::json!({}),
3258        )
3259        .await;
3260        assert!(before.iter().any(|a| a.id == "tpl-claude"));
3261
3262        // Hide the template.
3263        let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
3264            &engine,
3265            &mut rx,
3266            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
3267            serde_json::json!({ "definition_id": "tpl-claude" }),
3268        )
3269        .await;
3270        assert!(resp.ok);
3271
3272        // After hide: default listagents no longer surfaces it.
3273        let after: Vec<AgentDefinition> = call_rpc(
3274            &engine,
3275            &mut rx,
3276            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3277            serde_json::json!({}),
3278        )
3279        .await;
3280        assert!(
3281            !after.iter().any(|a| a.id == "tpl-claude"),
3282            "hidden template should NOT appear by default",
3283        );
3284
3285        // But user-owned rows (is_seeded=0) still appear — hide only
3286        // affects templates.
3287        assert!(after.iter().any(|a| a.id == "user-a"));
3288
3289        // include_hidden = true brings it back (settings panel surface).
3290        let included: Vec<AgentDefinition> = call_rpc(
3291            &engine,
3292            &mut rx,
3293            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3294            serde_json::json!({ "include_hidden": true }),
3295        )
3296        .await;
3297        let tpl = included
3298            .iter()
3299            .find(|a| a.id == "tpl-claude")
3300            .expect("hidden template should appear with include_hidden=true");
3301        assert_eq!(tpl.user_hidden, 1);
3302    }
3303
3304    #[tokio::test]
3305    async fn hide_then_unhide_round_trip() {
3306        let (_state, engine, mut rx) = build_state_with_template_seed();
3307        let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
3308            &engine,
3309            &mut rx,
3310            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
3311            serde_json::json!({ "definition_id": "tpl-claude" }),
3312        )
3313        .await;
3314        let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
3315            &engine,
3316            &mut rx,
3317            crate::backend::rpc_types::COMMAND_AGENT_DEF_UNHIDE,
3318            serde_json::json!({ "definition_id": "tpl-claude" }),
3319        )
3320        .await;
3321        assert!(resp.ok);
3322        // Listagents now shows it again, default-filter included.
3323        let agents: Vec<AgentDefinition> = call_rpc(
3324            &engine,
3325            &mut rx,
3326            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3327            serde_json::json!({}),
3328        )
3329        .await;
3330        let tpl = agents
3331            .iter()
3332            .find(|a| a.id == "tpl-claude")
3333            .expect("unhidden template should appear");
3334        assert_eq!(tpl.user_hidden, 0);
3335    }
3336
3337    #[tokio::test]
3338    async fn hide_rejects_user_owned_definition() {
3339        let (_state, engine, mut rx) = build_state_with_template_seed();
3340        // "user-a" is is_seeded=0 — hide must reject.
3341        let err = call_rpc_expect_error(
3342            &engine,
3343            &mut rx,
3344            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
3345            serde_json::json!({ "definition_id": "user-a" }),
3346        )
3347        .await;
3348        assert!(
3349            err.contains("not a seeded template"),
3350            "wrong error: {err}"
3351        );
3352    }
3353
3354    #[tokio::test]
3355    async fn hide_unknown_id_returns_ok_false() {
3356        let (_state, engine, mut rx) = build_state_with_template_seed();
3357        let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
3358            &engine,
3359            &mut rx,
3360            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
3361            serde_json::json!({ "definition_id": "no-such-id" }),
3362        )
3363        .await;
3364        assert!(!resp.ok);
3365    }
3366
3367    #[tokio::test]
3368    async fn list_hidden_templates_returns_only_hidden_templates() {
3369        let (_state, engine, mut rx) = build_state_with_template_seed();
3370        // Empty initially.
3371        let empty: Vec<AgentDefinition> = call_rpc(
3372            &engine,
3373            &mut rx,
3374            crate::backend::rpc_types::COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
3375            serde_json::json!({}),
3376        )
3377        .await;
3378        assert!(empty.is_empty());
3379
3380        // Hide one; expect it to surface.
3381        let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
3382            &engine,
3383            &mut rx,
3384            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
3385            serde_json::json!({ "definition_id": "tpl-claude" }),
3386        )
3387        .await;
3388        let hidden: Vec<AgentDefinition> = call_rpc(
3389            &engine,
3390            &mut rx,
3391            crate::backend::rpc_types::COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
3392            serde_json::json!({}),
3393        )
3394        .await;
3395        assert_eq!(hidden.len(), 1);
3396        assert_eq!(hidden[0].id, "tpl-claude");
3397        assert_eq!(hidden[0].is_seeded, 1);
3398        assert_eq!(hidden[0].user_hidden, 1);
3399    }
3400
3401    #[tokio::test]
3402    async fn listagents_is_seeded_filter_with_include_hidden_combines() {
3403        // Templates-only filter + include_hidden = the settings panel's
3404        // canonical query if it ever wanted the full template universe.
3405        // Without include_hidden + is_seeded=1 the hidden ones drop out.
3406        let (_state, engine, mut rx) = build_state_with_template_seed();
3407        let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
3408            &engine,
3409            &mut rx,
3410            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
3411            serde_json::json!({ "definition_id": "tpl-claude" }),
3412        )
3413        .await;
3414        let templates_visible: Vec<AgentDefinition> = call_rpc(
3415            &engine,
3416            &mut rx,
3417            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3418            serde_json::json!({ "is_seeded": 1 }),
3419        )
3420        .await;
3421        assert!(
3422            !templates_visible.iter().any(|a| a.id == "tpl-claude"),
3423            "hidden template should be excluded from is_seeded=1 default query",
3424        );
3425        let templates_all: Vec<AgentDefinition> = call_rpc(
3426            &engine,
3427            &mut rx,
3428            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3429            serde_json::json!({ "is_seeded": 1, "include_hidden": true }),
3430        )
3431        .await;
3432        assert!(templates_all.iter().any(|a| a.id == "tpl-claude"));
3433    }
3434}