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    COMMAND_CONTAINER_RUNTIME_AVAILABLE,
23    CommandAgentDefCreateFromTemplateData, AgentDefCreateFromTemplateResult,
24    CommandListAgentDefinitionsData,
25    // Two-tier picker — Phase 2 (SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md
26    // Q2 Decision Y: hide templates).
27    COMMAND_AGENT_DEF_HIDE, COMMAND_AGENT_DEF_UNHIDE,
28    COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
29    CommandAgentDefHideData, AgentDefHideResult,
30    CommandCreateAgentDefinitionData, CommandUpdateAgentDefinitionData, CommandDeleteAgentDefinitionData,
31    CommandGetAgentContentData, CommandSetAgentContentData, CommandGetAllAgentContentData,
32    CommandListAgentSkillsData, CommandCreateAgentSkillData, CommandUpdateAgentSkillData,
33    CommandDeleteAgentSkillData,
34    CommandAppendAgentHistoryData, CommandListAgentHistoryData, CommandSearchAgentHistoryData,
35    CommandImportAgentFromClawData,
36    CommandImportAgentDefinitionsData, ImportAgentDefinitionsResult,
37    ExportAgentDefinitionsResult, AgentDefinitionExport, AgentSkillExport,
38    // v6 identity / instance / fork
39    COMMAND_LIST_IDENTITY_ACCOUNTS, COMMAND_GET_IDENTITY_ACCOUNT,
40    COMMAND_UPSERT_IDENTITY_ACCOUNT, COMMAND_DELETE_IDENTITY_ACCOUNT,
41    COMMAND_ACCOUNT_KEY_VERIFY,
42    COMMAND_ACCOUNT_OAUTH_START, COMMAND_ACCOUNT_OAUTH_POLL, COMMAND_ACCOUNT_OAUTH_CANCEL,
43    COMMAND_LINK_AGENT_IDENTITY, COMMAND_UNLINK_AGENT_IDENTITY,
44    COMMAND_LIST_AGENT_IDENTITIES,
45    COMMAND_LIST_AGENT_INSTANCES, COMMAND_GET_AGENT_INSTANCE,
46    COMMAND_CREATE_AGENT_INSTANCE, COMMAND_UPDATE_AGENT_INSTANCE,
47    COMMAND_DELETE_AGENT_INSTANCE,
48    COMMAND_LIST_NAMED_AGENTS, COMMAND_HIDE_NAMED_AGENT,
49    CommandListNamedAgentsData, CommandHideNamedAgentData,
50    NamedAgentRow,
51    COMMAND_LIST_RECENT_SESSIONS, CommandListRecentSessionsData,
52    RecentSessionRow,
53    // Option E (PR 1 of 2) — agent-anchored session zones.
54    COMMAND_AGENT_SESSION_READ, COMMAND_AGENT_SESSION_WRITE_STATE,
55    COMMAND_AGENT_SESSION_APPEND_OUTPUT, COMMAND_AGENT_SESSION_ARCHIVE,
56    COMMAND_AGENT_SESSION_LIST_ARCHIVES,
57    CommandAgentSessionReadData, AgentSessionReadResult,
58    CommandAgentSessionWriteStateData, AgentSessionWriteStateResult,
59    CommandAgentSessionAppendOutputData, AgentSessionAppendOutputResult,
60    CommandAgentSessionArchiveData, AgentSessionArchiveResult,
61    CommandAgentSessionListArchivesData, AgentArchiveRow,
62    COMMAND_FORK_AGENT_DEFINITION,
63    COMMAND_FORK_AGENT_DEFINITION_SUGGEST,
64    CommandForkAgentDefinitionSuggestData, ForkAgentDefinitionSuggestResult,
65    CommandListIdentityAccountsData, CommandGetIdentityAccountData,
66    CommandDeleteIdentityAccountData,
67    CommandLinkAgentIdentityData, CommandUnlinkAgentIdentityData,
68    CommandListAgentIdentitiesData,
69    CommandListAgentInstancesData, CommandGetAgentInstanceData,
70    CommandCreateAgentInstanceData, CommandUpdateAgentInstanceData,
71    CommandDeleteAgentInstanceData,
72    CommandForkAgentDefinitionData,
73    // v7 Identity bundles + Memory
74    COMMAND_LIST_IDENTITY_BUNDLES, COMMAND_GET_IDENTITY_BUNDLE,
75    COMMAND_UPSERT_IDENTITY_BUNDLE, COMMAND_DELETE_IDENTITY_BUNDLE,
76    COMMAND_BIND_IDENTITY_ACCOUNT, COMMAND_UNBIND_IDENTITY_ACCOUNT,
77    COMMAND_LIST_IDENTITY_BINDINGS,
78    COMMAND_LIST_MEMORIES, COMMAND_GET_MEMORY,
79    COMMAND_UPSERT_MEMORY, COMMAND_DELETE_MEMORY, COMMAND_REORDER_GLOBAL_BRAIN,
80    CommandGetIdentityBundleData, CommandDeleteIdentityBundleData,
81    CommandBindIdentityAccountData, CommandUnbindIdentityAccountData,
82    CommandListIdentityBindingsData,
83    CommandGetMemoryData, CommandDeleteMemoryData, CommandReorderGlobalBrainData,
84};
85use crate::backend::storage::{AgentDefinition, AgentContent, AgentSkill};
86use crate::backend::storage::store::{
87    AgentInstance, Identity, IdentityAccount, InstanceStatus, Memory, SecretRef,
88};
89use crate::backend::rpc_types::{
90    COMMAND_SUBPROCESS_SPAWN, COMMAND_AGENT_INPUT, COMMAND_AGENT_STOP,
91    CommandSubprocessSpawnData, CommandAgentInputData, CommandAgentStopData,
92};
93use crate::backend::obj::Block;
94use crate::backend::blockcontroller;
95
96use super::AppState;
97
98/// Request for `account.key.verify` (Trust Center key flow). The `api_key`
99/// field is a secret — never log this struct.
100#[derive(serde::Deserialize)]
101#[serde(rename_all = "camelCase")]
102struct VerifyKeyReq {
103    /// Service id: "github" | "openai" | "anthropic" | "slack" | … .
104    provider: String,
105    /// Account display name (user-chosen label).
106    name: String,
107    #[serde(default)]
108    display_name: String,
109    /// Account kind; defaults to "api_key" when empty.
110    #[serde(default)]
111    kind: String,
112    /// The pasted secret. Used once to (optionally) validate + store in the
113    /// OS keychain, then dropped. Never persisted in the DB, never logged.
114    api_key: String,
115    /// When true, run a live validation probe before storing (user clicked
116    /// "Validate"). When false, store with status "unknown" (the "Save
117    /// without validating" air-gapped path).
118    #[serde(default)]
119    validate: bool,
120    /// Set to replace the key on an existing account; empty mints a new one.
121    #[serde(default)]
122    account_id: String,
123    /// User-entered, non-secret context (github_username, scopes, notes, …).
124    /// Merged over any existing context so editing a key never wipes fields
125    /// the user set previously.
126    #[serde(default)]
127    context: serde_json::Value,
128}
129
130/// Request for `account.oauth.start`. `clientId`/`clientSecret` are BYO OAuth
131/// app credentials (optional; required for secret-mandatory providers).
132#[derive(serde::Deserialize)]
133#[serde(rename_all = "camelCase")]
134struct OAuthStartReq {
135    provider: String,
136    name: String,
137    #[serde(default)]
138    client_id: Option<String>,
139    #[serde(default)]
140    client_secret: Option<String>,
141}
142
143#[derive(serde::Deserialize)]
144#[serde(rename_all = "camelCase")]
145struct OAuthSessionReq {
146    session_id: String,
147}
148
149/// Serialize an OAuthStatus to the frontend wire shape.
150fn oauth_status_wire(s: &crate::identity::oauth_client::OAuthStatus) -> serde_json::Value {
151    use crate::identity::oauth_client::OAuthStatus;
152    match s {
153        OAuthStatus::Pending => serde_json::json!({ "status": "pending" }),
154        OAuthStatus::UrlAvailable { auth_url } => {
155            serde_json::json!({ "status": "url-available", "authUrl": auth_url })
156        }
157        OAuthStatus::CodeEmitted { user_code, verification_uri } => serde_json::json!({
158            "status": "code-emitted",
159            "userCode": user_code,
160            "verificationUri": verification_uri,
161        }),
162        OAuthStatus::Success { account_id } => {
163            serde_json::json!({ "status": "success", "accountId": account_id })
164        }
165        OAuthStatus::Failed { error } => serde_json::json!({ "status": "failed", "error": error }),
166    }
167}
168
169/// Shallow-merge the keys of `overlay` (if both are JSON objects) into `base`.
170fn merge_json_object(base: &mut serde_json::Value, overlay: &serde_json::Value) {
171    if let (Some(b), Some(o)) = (base.as_object_mut(), overlay.as_object()) {
172        for (k, v) in o {
173            b.insert(k.clone(), v.clone());
174        }
175    }
176}
177
178pub fn register_agent_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
179    // listagents → return all agent definitions, optionally filtered by
180    // `is_seeded`. Filter input is backward-compatible: callers that
181    // pass `null` / `{}` (every existing caller) get the full list.
182    // The two-tier picker (Phase 1) passes `{ is_seeded: 0 }` for the
183    // "My Agents" section and `{ is_seeded: 1 }` for the "Templates"
184    // section — see SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md.
185    //
186    // Phase 2 (Q2 Decision Y — hide templates): templates with
187    // `user_hidden = 1` are filtered out by default. Callers that want
188    // them back (the settings panel's "Hidden templates" surface) pass
189    // `include_hidden: true`. Hide filter applies ONLY to templates —
190    // user-owned definitions are unaffected (their `user_hidden` is
191    // always 0 by backend invariant; `agent_def_set_hidden` rejects
192    // non-template ids).
193    let wstore_lfa = state.wstore.clone();
194    engine.register_handler(
195        COMMAND_LIST_AGENTS,
196        Box::new(move |data, _ctx| {
197            let wstore = wstore_lfa.clone();
198            Box::pin(async move {
199                // unwrap_or_default — both `null` and `{}` deserialize
200                // to the default (no filter). Anything malformed falls
201                // back to no-filter rather than erroring; older clients
202                // never sent a body for this RPC and we can't know
203                // which JSON shape they're on.
204                let cmd: CommandListAgentDefinitionsData =
205                    serde_json::from_value(data).unwrap_or_default();
206                let agents = wstore.agent_def_list().map_err(|e| format!("listagents: {e}"))?;
207                let is_seeded_filter = cmd.is_seeded;
208                let include_hidden = cmd.include_hidden;
209                let filtered: Vec<_> = agents
210                    .into_iter()
211                    .filter(|a| match is_seeded_filter {
212                        Some(flag) => a.is_seeded == flag,
213                        None => true,
214                    })
215                    // Default behaviour: drop hidden templates. The
216                    // settings panel opts back in with include_hidden.
217                    // User-owned rows (is_seeded == 0) are never
218                    // hideable; the conditional below is a no-op for
219                    // them.
220                    .filter(|a| {
221                        include_hidden || a.is_seeded != 1 || a.user_hidden == 0
222                    })
223                    .collect();
224                Ok(Some(serde_json::to_value(&filtered).unwrap_or_default()))
225            })
226        }),
227    );
228
229    // createagent → insert new agent, broadcast agents:changed
230    let wstore_cfa = state.wstore.clone();
231    let broker_cfa = state.broker.clone();
232    engine.register_handler(
233        COMMAND_CREATE_AGENT,
234        Box::new(move |data, _ctx| {
235            let wstore = wstore_cfa.clone();
236            let broker = broker_cfa.clone();
237            Box::pin(async move {
238                let cmd: CommandCreateAgentDefinitionData = serde_json::from_value(data)
239                    .map_err(|e| format!("createagent: {e}"))?;
240                let now = SystemTime::now()
241                    .duration_since(UNIX_EPOCH)
242                    .unwrap_or_default()
243                    .as_millis() as i64;
244                // slug is empty here — agent_def_insert auto-derives it
245                // from name AND collision-resolves AND mutates the
246                // struct so we serialize the resolved value back to
247                // the frontend (not "").
248                let mut agent = AgentDefinition {
249                    id: uuid::Uuid::new_v4().to_string(),
250                    slug: String::new(),
251                    name: cmd.name,
252                    icon: cmd.icon,
253                    provider: cmd.provider,
254                    description: cmd.description,
255                    working_directory: cmd.working_directory,
256                    shell: cmd.shell,
257                    provider_flags: cmd.provider_flags,
258                    auto_start: cmd.auto_start,
259                    restart_on_crash: cmd.restart_on_crash,
260                    idle_timeout_minutes: cmd.idle_timeout_minutes,
261                    created_at: now,
262                    agent_type: cmd.agent_type,
263                    environment: cmd.environment,
264                    agent_bus_id: cmd.agent_bus_id,
265                    is_seeded: 0,
266                    accounts: String::new(),
267                    parent_id: String::new(),
268                    branch_label: String::new(),
269                    updated_at: now,
270                    user_hidden: 0,
271                    container_image: String::new(),
272                    container_volumes: "[]".to_string(),
273                    container_name: String::new(),
274                };
275                wstore.agent_def_insert(&mut agent).map_err(|e| format!("createagent: {e}"))?;
276                broker.publish(crate::backend::wps::WaveEvent {
277                    event: "agents:changed".to_string(),
278                    scopes: vec![],
279                    sender: String::new(),
280                    persist: 0,
281                    data: None,
282                });
283                Ok(Some(serde_json::to_value(&agent).unwrap_or_default()))
284            })
285        }),
286    );
287
288    // updateagent → update existing agent, broadcast agents:changed
289    let wstore_ufa = state.wstore.clone();
290    let broker_ufa = state.broker.clone();
291    engine.register_handler(
292        COMMAND_UPDATE_AGENT,
293        Box::new(move |data, _ctx| {
294            let wstore = wstore_ufa.clone();
295            let broker = broker_ufa.clone();
296            Box::pin(async move {
297                let cmd: CommandUpdateAgentDefinitionData = serde_json::from_value(data)
298                    .map_err(|e| format!("updateagent: {e}"))?;
299                // Fetch existing to preserve created_at
300                let existing = wstore.agent_def_list().map_err(|e| format!("updateagent: {e}"))?;
301                let old = existing.iter().find(|a| a.id == cmd.id)
302                    .ok_or_else(|| format!("updateagent: agent {} not found", cmd.id))?;
303                // slug is preserved from the existing row — it's
304                // immutable after creation. The update path never
305                // accepts a new slug from the client.
306                let mut agent = AgentDefinition {
307                    id: cmd.id,
308                    slug: old.slug.clone(),
309                    name: cmd.name,
310                    icon: cmd.icon,
311                    provider: cmd.provider,
312                    description: cmd.description,
313                    working_directory: cmd.working_directory,
314                    shell: cmd.shell,
315                    provider_flags: cmd.provider_flags,
316                    auto_start: cmd.auto_start,
317                    restart_on_crash: cmd.restart_on_crash,
318                    idle_timeout_minutes: cmd.idle_timeout_minutes,
319                    created_at: old.created_at,
320                    agent_type: cmd.agent_type,
321                    environment: cmd.environment,
322                    agent_bus_id: cmd.agent_bus_id,
323                    is_seeded: old.is_seeded,
324                    // Preserve existing accounts when the caller omits the field
325                    // (cmd.accounts defaults to "" via #[serde(default)]). Callers
326                    // that only update name/icon/etc. (AgentDefForm, AgentPicker rename)
327                    // don't carry accounts, so falling back to old.accounts prevents
328                    // silently wiping saved assignments.
329                    accounts: if cmd.accounts.is_empty() { old.accounts.clone() } else { cmd.accounts },
330                    // parent_id + branch_label describe provenance and
331                    // are immutable post-insert (forks are separate rows,
332                    // not in-place edits).
333                    parent_id: old.parent_id.clone(),
334                    branch_label: old.branch_label.clone(),
335                    // Placeholder — agent_def_update self-stamps the real
336                    // timestamp and writes it back into `agent` below, so
337                    // the response body carries the fresh value.
338                    updated_at: old.updated_at,
339                    // Preserve user_hidden — updateagent edits the
340                    // definition payload, not the per-user view-state
341                    // flag. Hide/unhide go through their dedicated RPCs
342                    // (`agentdefhide` / `agentdefunhide`). Phase 2 of
343                    // SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md.
344                    user_hidden: old.user_hidden,
345                    // Preserve existing container config when the caller omits the field
346                    // (cmd.container_image defaults to "" via #[serde(default)]). Callers
347                    // that only update name/icon/etc. (AgentDefForm) don't carry container
348                    // fields, so falling back to old values prevents silently wiping a
349                    // container agent's image and volumes — same guard as `accounts` above.
350                    container_image: if cmd.container_image.is_empty() { old.container_image.clone() } else { cmd.container_image },
351                    container_volumes: if cmd.container_volumes == "[]" { old.container_volumes.clone() } else { cmd.container_volumes },
352                    // container_name is server-managed; preserve the existing value.
353                    container_name: old.container_name.clone(),
354                };
355                let found = wstore.agent_def_update(&mut agent).map_err(|e| format!("updateagent: {e}"))?;
356                if !found {
357                    return Err(format!("updateagent: agent {} not found", agent.id));
358                }
359                broker.publish(crate::backend::wps::WaveEvent {
360                    event: "agents:changed".to_string(),
361                    scopes: vec![],
362                    sender: String::new(),
363                    persist: 0,
364                    data: None,
365                });
366                Ok(Some(serde_json::to_value(&agent).unwrap_or_default()))
367            })
368        }),
369    );
370
371    // deleteagent → delete agent by id, broadcast agents:changed
372    let wstore_dfa = state.wstore.clone();
373    let broker_dfa = state.broker.clone();
374    engine.register_handler(
375        COMMAND_DELETE_AGENT,
376        Box::new(move |data, _ctx| {
377            let wstore = wstore_dfa.clone();
378            let broker = broker_dfa.clone();
379            Box::pin(async move {
380                let cmd: CommandDeleteAgentDefinitionData = serde_json::from_value(data)
381                    .map_err(|e| format!("deleteagent: {e}"))?;
382                wstore.agent_def_delete(&cmd.id).map_err(|e| format!("deleteagent: {e}"))?;
383                broker.publish(crate::backend::wps::WaveEvent {
384                    event: "agents:changed".to_string(),
385                    scopes: vec![],
386                    sender: String::new(),
387                    persist: 0,
388                    data: None,
389                });
390                Ok(None)
391            })
392        }),
393    );
394
395    // agentdefcreatefromtemplate → clone a seeded template into a new
396    // user-owned definition (Phase 1 two-tier picker —
397    // SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md). The template stays
398    // pristine; the new row carries `is_seeded = 0`. Returns the new
399    // definition_id so the frontend can immediately launch.
400    //
401    // Validation rules:
402    //  - `template_id` MUST resolve to a row with `is_seeded = 1`.
403    //    Cloning a user-owned row would be confusing semantics — use
404    //    the existing `forkagentdefinition` RPC for that case.
405    //  - `name` non-empty, ≤200 chars, and not already taken by any
406    //    `is_seeded = 0` row. Avoids collisions in the picker's
407    //    "My Agents" list.
408    let wstore_act = state.wstore.clone();
409    let broker_act = state.broker.clone();
410    engine.register_handler(
411        COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
412        Box::new(move |data, _ctx| {
413            let wstore = wstore_act.clone();
414            let broker = broker_act.clone();
415            Box::pin(async move {
416                let cmd: CommandAgentDefCreateFromTemplateData = serde_json::from_value(data)
417                    .map_err(|e| format!("agentdefcreatefromtemplate: {e}"))?;
418                let name = cmd.name.trim().to_string();
419                if name.is_empty() {
420                    return Err("agentdefcreatefromtemplate: name must be non-empty".into());
421                }
422                if name.chars().count() > 200 {
423                    return Err(
424                        "agentdefcreatefromtemplate: name must be ≤200 characters".into(),
425                    );
426                }
427
428                let all = wstore
429                    .agent_def_list()
430                    .map_err(|e| format!("agentdefcreatefromtemplate: list: {e}"))?;
431                let template = all
432                    .iter()
433                    .find(|a| a.id == cmd.template_id)
434                    .ok_or_else(|| {
435                        format!(
436                            "agentdefcreatefromtemplate: template {} not found",
437                            cmd.template_id
438                        )
439                    })?;
440                if template.is_seeded != 1 {
441                    return Err(format!(
442                        "agentdefcreatefromtemplate: {} is not a seeded template (is_seeded={})",
443                        cmd.template_id, template.is_seeded
444                    ));
445                }
446                if all
447                    .iter()
448                    .any(|a| a.is_seeded == 0 && a.name.eq_ignore_ascii_case(&name))
449                {
450                    return Err(format!(
451                        "agentdefcreatefromtemplate: an agent named {:?} already exists",
452                        name
453                    ));
454                }
455
456                let now = SystemTime::now()
457                    .duration_since(UNIX_EPOCH)
458                    .map(|d| d.as_millis() as i64)
459                    .unwrap_or(0);
460                // Runtime is the user's instantiation-time choice, not a
461                // template property. When supplied, the clone records it
462                // (and the matching `environment`); empty falls back to
463                // the template's value for back-compat with older callers.
464                let chosen_agent_type = match cmd.agent_type.trim() {
465                    "host" | "container" => cmd.agent_type.trim().to_string(),
466                    _ => template.agent_type.clone(),
467                };
468                let chosen_environment = if chosen_agent_type == "container" {
469                    "docker".to_string()
470                } else {
471                    "local".to_string()
472                };
473                let mut new_def = AgentDefinition {
474                    id: uuid::Uuid::new_v4().to_string(),
475                    // agent_def_insert derives a unique slug from the
476                    // name when this is empty + collision-resolves.
477                    slug: String::new(),
478                    name: name.clone(),
479                    icon: template.icon.clone(),
480                    provider: template.provider.clone(),
481                    description: template.description.clone(),
482                    // Force re-allocation of the per-agent working
483                    // directory at first launch via the new slug —
484                    // matches forkagentdefinition's behaviour.
485                    working_directory: String::new(),
486                    shell: template.shell.clone(),
487                    provider_flags: template.provider_flags.clone(),
488                    // Users opt in to auto-start explicitly; cloning
489                    // shouldn't carry it over (mirrors fork).
490                    auto_start: 0,
491                    restart_on_crash: template.restart_on_crash,
492                    idle_timeout_minutes: template.idle_timeout_minutes,
493                    created_at: now,
494                    agent_type: chosen_agent_type,
495                    environment: chosen_environment,
496                    agent_bus_id: String::new(),
497                    is_seeded: 0,
498                    accounts: String::new(),
499                    parent_id: template.id.clone(),
500                    branch_label: String::new(),
501                    updated_at: now,
502                    // New user-owned agent starts visible. Phase 2
503                    // (Q2 Decision Y) — hide applies only to seeded
504                    // templates, never to user-owned agents.
505                    user_hidden: 0,
506                    // Inherit container config from template so container-type
507                    // templates propagate their image to user-cloned agents.
508                    container_image: template.container_image.clone(),
509                    container_volumes: template.container_volumes.clone(),
510                    container_name: String::new(),
511                };
512                wstore
513                    .agent_def_insert(&mut new_def)
514                    .map_err(|e| format!("agentdefcreatefromtemplate: insert: {e}"))?;
515
516                broker.publish(crate::backend::wps::WaveEvent {
517                    event: "agents:changed".to_string(),
518                    scopes: vec![],
519                    sender: String::new(),
520                    persist: 0,
521                    data: None,
522                });
523
524                let resp = AgentDefCreateFromTemplateResult {
525                    definition_id: new_def.id.clone(),
526                    identity_id: cmd.identity_id,
527                    memory_id: cmd.memory_id,
528                };
529                tracing::info!(
530                    template_id = %cmd.template_id,
531                    new_definition_id = %new_def.id,
532                    new_name = %new_def.name,
533                    "agentdefcreatefromtemplate: cloned template into user agent"
534                );
535                Ok(Some(serde_json::to_value(&resp).unwrap_or_default()))
536            })
537        }),
538    );
539
540    // containerruntimeavailable → does the Docker DAEMON answer a ping
541    // right now? Returns `{ available: bool }`. The create-from-template
542    // modal uses this to gate/default the container runtime. Distinct
543    // from `resolvecli docker`, which only confirms the CLI binary is on
544    // PATH — that false-positives when Docker is installed but the daemon
545    // is stopped, steering users into a container agent that can't start
546    // (codex P1 on #1576). `None` manager (Docker absent at startup) →
547    // false; `Some` → live `check_available()` ping so a daemon that
548    // came up or went down since launch is reflected.
549    let container_manager_cra = state.container_manager.clone();
550    engine.register_handler(
551        COMMAND_CONTAINER_RUNTIME_AVAILABLE,
552        Box::new(move |_data, _ctx| {
553            let cm = container_manager_cra.clone();
554            Box::pin(async move {
555                let available = match cm {
556                    Some(mgr) => mgr.check_available().await.is_ok(),
557                    None => false,
558                };
559                Ok(Some(serde_json::json!({ "available": available })))
560            })
561        }),
562    );
563
564    // agentdefhide → set user_hidden = 1 on a seeded template, so it
565    // disappears from the picker's "+ New from template" tier. Phase 2
566    // (Q2 Decision Y) of SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md.
567    //
568    // Validation:
569    //  - `definition_id` MUST exist. Missing → returns `{ ok: false }`.
570    //  - The row MUST be a seeded template (`is_seeded = 1`). User-owned
571    //    rows reject with a hard error — they have their own delete path
572    //    and a hide flag on them would be misleading.
573    //
574    // Broadcasts `agents:changed` so the picker refetches and the card
575    // disappears (existing list query already excludes hidden by default).
576    let wstore_hide = state.wstore.clone();
577    let broker_hide = state.broker.clone();
578    engine.register_handler(
579        COMMAND_AGENT_DEF_HIDE,
580        Box::new(move |data, _ctx| {
581            let wstore = wstore_hide.clone();
582            let broker = broker_hide.clone();
583            Box::pin(async move {
584                let cmd: CommandAgentDefHideData = serde_json::from_value(data)
585                    .map_err(|e| format!("agentdefhide: {e}"))?;
586                let ok = wstore
587                    .agent_def_set_hidden(&cmd.definition_id, true)
588                    .map_err(|e| format!("agentdefhide: {e}"))?;
589                if ok {
590                    broker.publish(crate::backend::wps::WaveEvent {
591                        event: "agents:changed".to_string(),
592                        scopes: vec![],
593                        sender: String::new(),
594                        persist: 0,
595                        data: None,
596                    });
597                    tracing::info!(
598                        definition_id = %cmd.definition_id,
599                        "agentdefhide: hid template"
600                    );
601                }
602                let resp = AgentDefHideResult { ok };
603                Ok(Some(serde_json::to_value(&resp).unwrap_or_default()))
604            })
605        }),
606    );
607
608    // agentdefunhide → set user_hidden = 0 on a seeded template,
609    // bringing it back into the picker. Same validation + broadcast as
610    // agentdefhide. Phase 2 of the two-tier picker spec.
611    let wstore_unhide = state.wstore.clone();
612    let broker_unhide = state.broker.clone();
613    engine.register_handler(
614        COMMAND_AGENT_DEF_UNHIDE,
615        Box::new(move |data, _ctx| {
616            let wstore = wstore_unhide.clone();
617            let broker = broker_unhide.clone();
618            Box::pin(async move {
619                let cmd: CommandAgentDefHideData = serde_json::from_value(data)
620                    .map_err(|e| format!("agentdefunhide: {e}"))?;
621                let ok = wstore
622                    .agent_def_set_hidden(&cmd.definition_id, false)
623                    .map_err(|e| format!("agentdefunhide: {e}"))?;
624                if ok {
625                    broker.publish(crate::backend::wps::WaveEvent {
626                        event: "agents:changed".to_string(),
627                        scopes: vec![],
628                        sender: String::new(),
629                        persist: 0,
630                        data: None,
631                    });
632                    tracing::info!(
633                        definition_id = %cmd.definition_id,
634                        "agentdefunhide: unhid template"
635                    );
636                }
637                let resp = AgentDefHideResult { ok };
638                Ok(Some(serde_json::to_value(&resp).unwrap_or_default()))
639            })
640        }),
641    );
642
643    // agentdeflisthiddentemplates → templates the user has hidden
644    // (is_seeded = 1 AND user_hidden = 1). Used by the settings panel
645    // to render the unhide list. The picker proper never calls this —
646    // it uses `listagents` with the default-filter-out behaviour.
647    let wstore_lh = state.wstore.clone();
648    engine.register_handler(
649        COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
650        Box::new(move |_data, _ctx| {
651            let wstore = wstore_lh.clone();
652            Box::pin(async move {
653                let agents = wstore
654                    .agent_def_list()
655                    .map_err(|e| format!("agentdeflisthiddentemplates: {e}"))?;
656                let hidden: Vec<_> = agents
657                    .into_iter()
658                    .filter(|a| a.is_seeded == 1 && a.user_hidden == 1)
659                    .collect();
660                Ok(Some(serde_json::to_value(&hidden).unwrap_or_default()))
661            })
662        }),
663    );
664
665    // getagentcontent → return a single content blob for an agent
666    let wstore_gfc = state.wstore.clone();
667    engine.register_handler(
668        COMMAND_GET_AGENT_CONTENT,
669        Box::new(move |data, _ctx| {
670            let wstore = wstore_gfc.clone();
671            Box::pin(async move {
672                let cmd: CommandGetAgentContentData = serde_json::from_value(data)
673                    .map_err(|e| format!("getagentcontent: {e}"))?;
674                let content = wstore.agent_content_get(&cmd.agent_id, &cmd.content_type)
675                    .map_err(|e| format!("getagentcontent: {e}"))?;
676                Ok(content.map(|c| serde_json::to_value(&c).unwrap_or_default()))
677            })
678        }),
679    );
680
681    // setagentcontent → upsert a content blob, broadcast agentcontent:changed
682    let wstore_sfc = state.wstore.clone();
683    let broker_sfc = state.broker.clone();
684    engine.register_handler(
685        COMMAND_SET_AGENT_CONTENT,
686        Box::new(move |data, _ctx| {
687            let wstore = wstore_sfc.clone();
688            let broker = broker_sfc.clone();
689            Box::pin(async move {
690                let cmd: CommandSetAgentContentData = serde_json::from_value(data)
691                    .map_err(|e| format!("setagentcontent: {e}"))?;
692                let now = SystemTime::now()
693                    .duration_since(UNIX_EPOCH)
694                    .unwrap_or_default()
695                    .as_millis() as i64;
696                let content = AgentContent {
697                    agent_id: cmd.agent_id,
698                    content_type: cmd.content_type,
699                    content: cmd.content,
700                    updated_at: now,
701                };
702                wstore.agent_content_set(&content).map_err(|e| format!("setagentcontent: {e}"))?;
703                broker.publish(crate::backend::wps::WaveEvent {
704                    event: "agentcontent:changed".to_string(),
705                    scopes: vec![],
706                    sender: String::new(),
707                    persist: 0,
708                    data: None,
709                });
710                Ok(Some(serde_json::to_value(&content).unwrap_or_default()))
711            })
712        }),
713    );
714
715    // getallagentcontent → return all content blobs for an agent
716    let wstore_gafc = state.wstore.clone();
717    engine.register_handler(
718        COMMAND_GET_ALL_AGENT_CONTENT,
719        Box::new(move |data, _ctx| {
720            let wstore = wstore_gafc.clone();
721            Box::pin(async move {
722                let cmd: CommandGetAllAgentContentData = serde_json::from_value(data)
723                    .map_err(|e| format!("getallagentcontent: {e}"))?;
724                let contents = wstore.agent_content_get_all(&cmd.agent_id)
725                    .map_err(|e| format!("getallagentcontent: {e}"))?;
726                Ok(Some(serde_json::to_value(&contents).unwrap_or_default()))
727            })
728        }),
729    );
730
731    // ── Agent Skills handlers ──────────────────────────────────────────────
732
733    // listagentskills → return all skills for an agent
734    let wstore_lfs = state.wstore.clone();
735    engine.register_handler(
736        COMMAND_LIST_AGENT_SKILLS,
737        Box::new(move |data, _ctx| {
738            let wstore = wstore_lfs.clone();
739            Box::pin(async move {
740                let cmd: CommandListAgentSkillsData = serde_json::from_value(data)
741                    .map_err(|e| format!("listagentskills: {e}"))?;
742                let skills = wstore.agent_skill_list(&cmd.agent_id)
743                    .map_err(|e| format!("listagentskills: {e}"))?;
744                Ok(Some(serde_json::to_value(&skills).unwrap_or_default()))
745            })
746        }),
747    );
748
749    // createagentskill → insert new skill, broadcast agentskills:changed
750    let wstore_cfs = state.wstore.clone();
751    let broker_cfs = state.broker.clone();
752    engine.register_handler(
753        COMMAND_CREATE_AGENT_SKILL,
754        Box::new(move |data, _ctx| {
755            let wstore = wstore_cfs.clone();
756            let broker = broker_cfs.clone();
757            Box::pin(async move {
758                let cmd: CommandCreateAgentSkillData = serde_json::from_value(data)
759                    .map_err(|e| format!("createagentskill: {e}"))?;
760                let now = SystemTime::now()
761                    .duration_since(UNIX_EPOCH)
762                    .unwrap_or_default()
763                    .as_millis() as i64;
764                let skill = AgentSkill {
765                    id: uuid::Uuid::new_v4().to_string(),
766                    agent_id: cmd.agent_id,
767                    name: cmd.name,
768                    trigger: cmd.trigger,
769                    skill_type: cmd.skill_type,
770                    description: cmd.description,
771                    content: cmd.content,
772                    created_at: now,
773                };
774                wstore.agent_skill_insert(&skill).map_err(|e| format!("createagentskill: {e}"))?;
775                broker.publish(crate::backend::wps::WaveEvent {
776                    event: "agentskills:changed".to_string(),
777                    scopes: vec![],
778                    sender: String::new(),
779                    persist: 0,
780                    data: None,
781                });
782                Ok(Some(serde_json::to_value(&skill).unwrap_or_default()))
783            })
784        }),
785    );
786
787    // updateagentskill → update existing skill, broadcast agentskills:changed
788    let wstore_ufs = state.wstore.clone();
789    let broker_ufs = state.broker.clone();
790    engine.register_handler(
791        COMMAND_UPDATE_AGENT_SKILL,
792        Box::new(move |data, _ctx| {
793            let wstore = wstore_ufs.clone();
794            let broker = broker_ufs.clone();
795            Box::pin(async move {
796                let cmd: CommandUpdateAgentSkillData = serde_json::from_value(data)
797                    .map_err(|e| format!("updateagentskill: {e}"))?;
798                let existing = wstore.agent_skill_get(&cmd.id)
799                    .map_err(|e| format!("updateagentskill: {e}"))?
800                    .ok_or_else(|| format!("updateagentskill: skill {} not found", cmd.id))?;
801                let skill = AgentSkill {
802                    id: cmd.id,
803                    agent_id: existing.agent_id,
804                    name: cmd.name,
805                    trigger: cmd.trigger,
806                    skill_type: cmd.skill_type,
807                    description: cmd.description,
808                    content: cmd.content,
809                    created_at: existing.created_at,
810                };
811                let found = wstore.agent_skill_update(&skill).map_err(|e| format!("updateagentskill: {e}"))?;
812                if !found {
813                    return Err(format!("updateagentskill: skill {} not found", skill.id));
814                }
815                broker.publish(crate::backend::wps::WaveEvent {
816                    event: "agentskills:changed".to_string(),
817                    scopes: vec![],
818                    sender: String::new(),
819                    persist: 0,
820                    data: None,
821                });
822                Ok(Some(serde_json::to_value(&skill).unwrap_or_default()))
823            })
824        }),
825    );
826
827    // deleteagentskill → delete skill by id, broadcast agentskills:changed
828    let wstore_dfs = state.wstore.clone();
829    let broker_dfs = state.broker.clone();
830    engine.register_handler(
831        COMMAND_DELETE_AGENT_SKILL,
832        Box::new(move |data, _ctx| {
833            let wstore = wstore_dfs.clone();
834            let broker = broker_dfs.clone();
835            Box::pin(async move {
836                let cmd: CommandDeleteAgentSkillData = serde_json::from_value(data)
837                    .map_err(|e| format!("deleteagentskill: {e}"))?;
838                wstore.agent_skill_delete(&cmd.id).map_err(|e| format!("deleteagentskill: {e}"))?;
839                broker.publish(crate::backend::wps::WaveEvent {
840                    event: "agentskills:changed".to_string(),
841                    scopes: vec![],
842                    sender: String::new(),
843                    persist: 0,
844                    data: None,
845                });
846                Ok(None)
847            })
848        }),
849    );
850
851    // ── Agent History handlers ─────────────────────────────────────────────
852
853    // appendagenthistory → append a history entry, broadcast agenthistory:changed
854    let wstore_afh = state.wstore.clone();
855    let broker_afh = state.broker.clone();
856    engine.register_handler(
857        COMMAND_APPEND_AGENT_HISTORY,
858        Box::new(move |data, _ctx| {
859            let wstore = wstore_afh.clone();
860            let broker = broker_afh.clone();
861            Box::pin(async move {
862                let cmd: CommandAppendAgentHistoryData = serde_json::from_value(data)
863                    .map_err(|e| format!("appendagenthistory: {e}"))?;
864                let entry = wstore.agent_history_append(&cmd.agent_id, &cmd.entry)
865                    .map_err(|e| format!("appendagenthistory: {e}"))?;
866                broker.publish(crate::backend::wps::WaveEvent {
867                    event: "agenthistory:changed".to_string(),
868                    scopes: vec![],
869                    sender: String::new(),
870                    persist: 0,
871                    data: None,
872                });
873                Ok(Some(serde_json::to_value(&entry).unwrap_or_default()))
874            })
875        }),
876    );
877
878    // listagenthistory → return history entries with pagination
879    let wstore_lfh = state.wstore.clone();
880    engine.register_handler(
881        COMMAND_LIST_AGENT_HISTORY,
882        Box::new(move |data, _ctx| {
883            let wstore = wstore_lfh.clone();
884            Box::pin(async move {
885                let cmd: CommandListAgentHistoryData = serde_json::from_value(data)
886                    .map_err(|e| format!("listagenthistory: {e}"))?;
887                let entries = wstore.agent_history_list(
888                    &cmd.agent_id,
889                    cmd.session_date.as_deref(),
890                    cmd.limit,
891                    cmd.offset,
892                ).map_err(|e| format!("listagenthistory: {e}"))?;
893                Ok(Some(serde_json::to_value(&entries).unwrap_or_default()))
894            })
895        }),
896    );
897
898    // searchagenthistory → search history entries by query
899    let wstore_sfh = state.wstore.clone();
900    engine.register_handler(
901        COMMAND_SEARCH_AGENT_HISTORY,
902        Box::new(move |data, _ctx| {
903            let wstore = wstore_sfh.clone();
904            Box::pin(async move {
905                let cmd: CommandSearchAgentHistoryData = serde_json::from_value(data)
906                    .map_err(|e| format!("searchagenthistory: {e}"))?;
907                let entries = wstore.agent_history_search(&cmd.agent_id, &cmd.query, cmd.limit)
908                    .map_err(|e| format!("searchagenthistory: {e}"))?;
909                Ok(Some(serde_json::to_value(&entries).unwrap_or_default()))
910            })
911        }),
912    );
913
914    // ── Agent Import handler ───────────────────────────────────────────────
915
916    // importagentfromclaw → read claw workspace, create agent + content
917    let wstore_ifc = state.wstore.clone();
918    let broker_ifc = state.broker.clone();
919    engine.register_handler(
920        COMMAND_IMPORT_AGENT_FROM_CLAW,
921        Box::new(move |data, _ctx| {
922            let wstore = wstore_ifc.clone();
923            let broker = broker_ifc.clone();
924            Box::pin(async move {
925                let cmd: CommandImportAgentFromClawData = serde_json::from_value(data)
926                    .map_err(|e| format!("importagentfromclaw: {e}"))?;
927
928                let workspace_path = std::path::Path::new(&cmd.workspace_path);
929                if !workspace_path.exists() {
930                    return Err(format!("importagentfromclaw: path does not exist: {}", cmd.workspace_path));
931                }
932
933                let now = SystemTime::now()
934                    .duration_since(UNIX_EPOCH)
935                    .unwrap_or_default()
936                    .as_millis() as i64;
937
938                // Detect provider from .claude/settings.json if present
939                let mut provider = "claude".to_string();
940                let settings_path = workspace_path.join(".claude").join("settings.json");
941                if settings_path.exists() {
942                    if let Ok(settings_str) = std::fs::read_to_string(&settings_path) {
943                        if let Ok(settings) = serde_json::from_str::<serde_json::Value>(&settings_str) {
944                            if let Some(p) = settings.get("provider").and_then(|v| v.as_str()) {
945                                provider = p.to_string();
946                            }
947                        }
948                    }
949                }
950
951                // Create the agent — slug is empty, agent_def_insert will
952                // auto-derive from agent_name and mutate the struct
953                // so the resolved slug is returned to the frontend.
954                let mut agent = AgentDefinition {
955                    id: uuid::Uuid::new_v4().to_string(),
956                    slug: String::new(),
957                    name: cmd.agent_name.clone(),
958                    icon: "\u{2726}".to_string(),
959                    provider,
960                    description: format!("Imported from {}", cmd.workspace_path),
961                    working_directory: cmd.workspace_path.clone(),
962                    shell: String::new(),
963                    provider_flags: String::new(),
964                    auto_start: 0,
965                    restart_on_crash: 0,
966                    idle_timeout_minutes: 0,
967                    created_at: now,
968                    agent_type: "standalone".to_string(),
969                    environment: String::new(),
970                    agent_bus_id: String::new(),
971                    is_seeded: 0,
972                    accounts: String::new(),
973                    parent_id: String::new(),
974                    branch_label: String::new(),
975                    updated_at: now,
976                    user_hidden: 0,
977                    container_image: String::new(),
978                    container_volumes: "[]".to_string(),
979                    container_name: String::new(),
980                };
981                wstore.agent_def_insert(&mut agent).map_err(|e| format!("importagentfromclaw: {e}"))?;
982
983                // Read CLAUDE.md → agentmd content
984                let claude_md_path = workspace_path.join("CLAUDE.md");
985                if claude_md_path.exists() {
986                    if let Ok(content) = std::fs::read_to_string(&claude_md_path) {
987                        let fc = AgentContent {
988                            agent_id: agent.id.clone(),
989                            content_type: "agentmd".to_string(),
990                            content,
991                            updated_at: now,
992                        };
993                        let _ = wstore.agent_content_set(&fc);
994                    }
995                }
996
997                // Read .mcp.json → mcp content
998                let mcp_path = workspace_path.join(".mcp.json");
999                if mcp_path.exists() {
1000                    if let Ok(content) = std::fs::read_to_string(&mcp_path) {
1001                        let fc = AgentContent {
1002                            agent_id: agent.id.clone(),
1003                            content_type: "mcp".to_string(),
1004                            content,
1005                            updated_at: now,
1006                        };
1007                        let _ = wstore.agent_content_set(&fc);
1008                    }
1009                }
1010
1011                broker.publish(crate::backend::wps::WaveEvent {
1012                    event: "agents:changed".to_string(),
1013                    scopes: vec![],
1014                    sender: String::new(),
1015                    persist: 0,
1016                    data: None,
1017                });
1018                Ok(Some(serde_json::to_value(&agent).unwrap_or_default()))
1019            })
1020        }),
1021    );
1022
1023    // reseedagents → delete all seeded agents and re-run seed from manifest
1024    let wstore_rsfa = state.wstore.clone();
1025    let broker_rsfa = state.broker.clone();
1026    engine.register_handler(
1027        COMMAND_RESEED_AGENTS,
1028        Box::new(move |_data, _ctx| {
1029            let wstore = wstore_rsfa.clone();
1030            let broker = broker_rsfa.clone();
1031            Box::pin(async move {
1032                // Delete all previously seeded agents (cascade deletes content, skills, history)
1033                let deleted = wstore.agent_def_delete_seeded()
1034                    .map_err(|e| format!("reseedagents: delete seeded: {e}"))?;
1035
1036                // Re-run seed
1037                let report = crate::backend::agent_seed::seed_agents(&wstore)
1038                    .map_err(|e| format!("reseedagents: seed: {e}"))?;
1039
1040                broker.publish(crate::backend::wps::WaveEvent {
1041                    event: "agents:changed".to_string(),
1042                    scopes: vec![],
1043                    sender: String::new(),
1044                    persist: 0,
1045                    data: None,
1046                });
1047                Ok(Some(json!({
1048                    "deleted": deleted,
1049                    "created": report.created,
1050                    "skipped": report.skipped,
1051                })))
1052            })
1053        }),
1054    );
1055
1056    // importagents — bulk import from JSON export format
1057    let wstore_ifa = state.wstore.clone();
1058    let broker_ifa = state.broker.clone();
1059    engine.register_handler(
1060        COMMAND_IMPORT_AGENTS,
1061        Box::new(move |data, _ctx| {
1062            let wstore = wstore_ifa.clone();
1063            let broker = broker_ifa.clone();
1064            Box::pin(async move {
1065                let cmd: CommandImportAgentDefinitionsData = serde_json::from_value(data)
1066                    .map_err(|e| format!("importagents: {e}"))?;
1067
1068                let now = SystemTime::now()
1069                    .duration_since(UNIX_EPOCH)
1070                    .unwrap_or_default()
1071                    .as_millis() as i64;
1072
1073                let mut imported: Vec<String> = Vec::new();
1074                let mut skipped: Vec<String> = Vec::new();
1075                let mut failed: Vec<String> = Vec::new();
1076
1077                for agent_import in cmd.agents {
1078                    // Check for existing agent by slug (id field from export)
1079                    let existing = wstore.agent_def_list()
1080                        .unwrap_or_default()
1081                        .into_iter()
1082                        .any(|a| a.slug == agent_import.id);
1083
1084                    if existing {
1085                        skipped.push(agent_import.name.clone());
1086                        continue;
1087                    }
1088
1089                    let mut agent = AgentDefinition {
1090                        id: uuid::Uuid::new_v4().to_string(),
1091                        slug: agent_import.id.clone(),
1092                        name: agent_import.name.clone(),
1093                        icon: agent_import.icon.clone(),
1094                        provider: agent_import.provider.clone(),
1095                        description: agent_import.description.clone(),
1096                        working_directory: agent_import.working_directory.clone(),
1097                        shell: agent_import.shell.clone(),
1098                        provider_flags: String::new(),
1099                        auto_start: 0,
1100                        restart_on_crash: if agent_import.restart_on_crash { 1 } else { 0 },
1101                        idle_timeout_minutes: 0,
1102                        created_at: now,
1103                        agent_type: agent_import.agent_type.clone(),
1104                        environment: agent_import.environment.clone(),
1105                        agent_bus_id: agent_import.agent_bus_id.clone(),
1106                        is_seeded: 0,
1107                        accounts: String::new(),
1108                        parent_id: String::new(),
1109                        branch_label: String::new(),
1110                        updated_at: now,
1111                        user_hidden: 0,
1112                        container_image: String::new(),
1113                        container_volumes: "[]".to_string(),
1114                        container_name: String::new(),
1115                    };
1116
1117                    if let Err(e) = wstore.agent_def_insert(&mut agent) {
1118                        failed.push(format!("{}: {e}", agent_import.name));
1119                        continue;
1120                    }
1121
1122                    // Insert content types
1123                    let mut content_ok = true;
1124                    for (content_type, content) in &agent_import.content {
1125                        let fc = AgentContent {
1126                            agent_id: agent.id.clone(),
1127                            content_type: content_type.clone(),
1128                            content: content.clone(),
1129                            updated_at: now,
1130                        };
1131                        if let Err(e) = wstore.agent_content_set(&fc) {
1132                            tracing::warn!("import: failed to set content for agent {}: {e}", agent.id);
1133                            content_ok = false;
1134                        }
1135                    }
1136
1137                    // Insert skills
1138                    let mut skills_ok = true;
1139                    for skill_import in &agent_import.skills {
1140                        let skill = AgentSkill {
1141                            id: uuid::Uuid::new_v4().to_string(),
1142                            agent_id: agent.id.clone(),
1143                            name: skill_import.name.clone(),
1144                            trigger: skill_import.trigger.clone(),
1145                            skill_type: skill_import.skill_type.clone(),
1146                            description: skill_import.description.clone(),
1147                            content: skill_import.content.clone(),
1148                            created_at: now,
1149                        };
1150                        if let Err(e) = wstore.agent_skill_insert(&skill) {
1151                            tracing::warn!("import: failed to insert skill '{}' for agent {}: {e}", skill.name, agent.id);
1152                            skills_ok = false;
1153                        }
1154                    }
1155
1156                    if content_ok && skills_ok {
1157                        imported.push(agent_import.name.clone());
1158                    } else {
1159                        failed.push(agent_import.name.clone());
1160                    }
1161                }
1162
1163                broker.publish(crate::backend::wps::WaveEvent {
1164                    event: "agents:changed".to_string(),
1165                    scopes: vec![],
1166                    sender: String::new(),
1167                    persist: 0,
1168                    data: None,
1169                });
1170
1171                let result = ImportAgentDefinitionsResult { imported, skipped, failed };
1172                Ok(Some(serde_json::to_value(&result).unwrap_or_default()))
1173            })
1174        }),
1175    );
1176
1177    // exportagents — export all agent definitions with content and skills
1178    let wstore_efa = state.wstore.clone();
1179    engine.register_handler(
1180        COMMAND_EXPORT_AGENTS,
1181        Box::new(move |_data, _ctx| {
1182            let wstore = wstore_efa.clone();
1183            Box::pin(async move {
1184                let agents = wstore.agent_def_list()
1185                    .map_err(|e| format!("exportagents: list: {e}"))?;
1186
1187                let mut agent_exports: Vec<AgentDefinitionExport> = Vec::new();
1188
1189                for agent in agents {
1190                    let content_map = wstore.agent_content_get_all(&agent.id)
1191                        .unwrap_or_default()
1192                        .into_iter()
1193                        .map(|fc| (fc.content_type, fc.content))
1194                        .collect::<std::collections::HashMap<String, String>>();
1195
1196                    let skills = wstore.agent_skill_list(&agent.id)
1197                        .unwrap_or_default()
1198                        .into_iter()
1199                        .map(|s| AgentSkillExport {
1200                            name: s.name,
1201                            trigger: s.trigger,
1202                            skill_type: s.skill_type,
1203                            description: s.description,
1204                            content: s.content,
1205                        })
1206                        .collect::<Vec<_>>();
1207
1208                    agent_exports.push(AgentDefinitionExport {
1209                        id: agent.slug.clone(),
1210                        name: agent.name,
1211                        icon: agent.icon,
1212                        description: agent.description,
1213                        provider: agent.provider,
1214                        shell: agent.shell,
1215                        working_directory: agent.working_directory,
1216                        agent_bus_id: agent.agent_bus_id,
1217                        agent_type: agent.agent_type,
1218                        environment: agent.environment,
1219                        restart_on_crash: agent.restart_on_crash != 0,
1220                        content: content_map,
1221                        skills,
1222                    });
1223                }
1224
1225                let exported_at = Utc::now().to_rfc3339();
1226
1227                let result = ExportAgentDefinitionsResult {
1228                    version: 4,
1229                    exported_at,
1230                    source: "agentmux-export".to_string(),
1231                    agents: agent_exports,
1232                };
1233                Ok(Some(serde_json::to_value(&result).unwrap_or_default()))
1234            })
1235        }),
1236    );
1237
1238    register_v6_handlers(engine, state);
1239}
1240
1241/// v6 handlers — identity accounts, agent instances, definition branching.
1242/// See specs/SPEC_FORGE_IDENTITY_AGENT_INSTANCES_IMPL_2026_04_20.md §Phase 3.
1243fn register_v6_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
1244    // ---- Identity account CRUD ----
1245
1246    let wstore = state.wstore.clone();
1247    engine.register_handler(
1248        COMMAND_LIST_IDENTITY_ACCOUNTS,
1249        Box::new(move |data, _ctx| {
1250            let wstore = wstore.clone();
1251            Box::pin(async move {
1252                let cmd: CommandListIdentityAccountsData =
1253                    serde_json::from_value(data).unwrap_or_default();
1254                let accounts = wstore
1255                    .identity_list(cmd.provider.as_deref())
1256                    .map_err(|e| format!("listidentityaccounts: {e}"))?;
1257                Ok(Some(serde_json::to_value(&accounts).unwrap_or_default()))
1258            })
1259        }),
1260    );
1261
1262    let wstore = state.wstore.clone();
1263    engine.register_handler(
1264        COMMAND_GET_IDENTITY_ACCOUNT,
1265        Box::new(move |data, _ctx| {
1266            let wstore = wstore.clone();
1267            Box::pin(async move {
1268                let cmd: CommandGetIdentityAccountData =
1269                    serde_json::from_value(data).map_err(|e| format!("getidentityaccount: {e}"))?;
1270                match wstore
1271                    .identity_get(&cmd.id)
1272                    .map_err(|e| format!("getidentityaccount: {e}"))?
1273                {
1274                    Some(a) => Ok(Some(serde_json::to_value(&a).unwrap_or_default())),
1275                    None => Err(format!("getidentityaccount: not found id={}", cmd.id)),
1276                }
1277            })
1278        }),
1279    );
1280
1281    let wstore = state.wstore.clone();
1282    let broker = state.broker.clone();
1283    engine.register_handler(
1284        COMMAND_UPSERT_IDENTITY_ACCOUNT,
1285        Box::new(move |data, _ctx| {
1286            let wstore = wstore.clone();
1287            let broker = broker.clone();
1288            Box::pin(async move {
1289                // Accept the full IdentityAccount payload. Missing `id` → mint
1290                // a fresh UUID; `created_at` and `updated_at` are server-set
1291                // so callers don't have to know the current time.
1292                let mut account: IdentityAccount = serde_json::from_value(data)
1293                    .map_err(|e| format!("upsertidentityaccount: {e}"))?;
1294                if account.id.is_empty() {
1295                    account.id = uuid::Uuid::new_v4().to_string();
1296                }
1297                let now = SystemTime::now()
1298                    .duration_since(UNIX_EPOCH)
1299                    .map(|d| d.as_millis() as i64)
1300                    .unwrap_or(0);
1301                if account.created_at == 0 {
1302                    account.created_at = now;
1303                }
1304                account.updated_at = now;
1305                wstore
1306                    .identity_upsert(&account)
1307                    .map_err(|e| format!("upsertidentityaccount: {e}"))?;
1308                broker.publish(crate::backend::wps::WaveEvent {
1309                    event: "identityaccounts:changed".to_string(),
1310                    scopes: vec![],
1311                    sender: String::new(),
1312                    persist: 0,
1313                    data: None,
1314                });
1315                Ok(Some(serde_json::to_value(&account).unwrap_or_default()))
1316            })
1317        }),
1318    );
1319
1320    // Trust Center: validate (optional) + securely store an API key.
1321    // The plaintext goes to the OS keychain; the DB row keeps only the
1322    // SecretRef::Keychain pointer + masked tail + non-secret metadata.
1323    // See specs/SPEC_TRUST_CENTER_2026_06_15.md §5/§6.
1324    let wstore = state.wstore.clone();
1325    let broker = state.broker.clone();
1326    engine.register_handler(
1327        COMMAND_ACCOUNT_KEY_VERIFY,
1328        Box::new(move |data, _ctx| {
1329            let wstore = wstore.clone();
1330            let broker = broker.clone();
1331            Box::pin(async move {
1332                // NB: `req.api_key` is a secret — never log `req`.
1333                let req: VerifyKeyReq = serde_json::from_value(data)
1334                    .map_err(|e| format!("account.key.verify: {e}"))?;
1335
1336                // New account (mint id) vs. key replacement on an existing
1337                // account. Rollback semantics differ: see the upsert below.
1338                let is_new = req.account_id.is_empty();
1339                let account_id = if is_new {
1340                    uuid::Uuid::new_v4().to_string()
1341                } else {
1342                    req.account_id.clone()
1343                };
1344
1345                // Optional live validation — the single outbound probe, fired
1346                // only when the user clicked "Validate" (validate=true).
1347                let (status, metadata, masked_tail, valid) = if req.validate {
1348                    let outcome =
1349                        crate::identity::key_validator::validate(&req.provider, &req.api_key).await;
1350                    if !outcome.valid {
1351                        // Nothing stored — surface a structured error so the UI
1352                        // stays in the entry state.
1353                        return Ok(Some(serde_json::json!({
1354                            "valid": false,
1355                            "error": outcome.error.unwrap_or_else(|| "validation failed".to_string()),
1356                        })));
1357                    }
1358                    ("valid".to_string(), outcome.metadata, outcome.masked_tail, true)
1359                } else {
1360                    (
1361                        "unknown".to_string(),
1362                        serde_json::json!({}),
1363                        crate::identity::key_validator::masked_tail(&req.api_key),
1364                        false,
1365                    )
1366                };
1367
1368                // Store the plaintext in the OS keychain; the DB never sees it.
1369                // `keyring` is blocking (sync D-Bus on Linux), so run it off the
1370                // async runtime worker via spawn_blocking.
1371                {
1372                    let aid = account_id.clone();
1373                    let key = req.api_key.clone();
1374                    tokio::task::spawn_blocking(move || {
1375                        crate::identity::secret_store::put(&aid, &key)
1376                    })
1377                    .await
1378                    .map_err(|e| format!("account.key.verify: keychain task: {e}"))?
1379                    .map_err(|e| format!("account.key.verify: {e}"))?;
1380                }
1381
1382                // Fetch the existing row once (replacement path) — preserves
1383                // created_at and any previously-stored context.
1384                let existing = wstore.identity_get(&account_id).ok().flatten();
1385
1386                // Non-secret context, merged so nothing the user set is lost:
1387                //   existing context  →  user-entered context  →  validation
1388                //   metadata  →  masked tail.
1389                let mut context = existing
1390                    .as_ref()
1391                    .map(|a| a.context.clone())
1392                    .unwrap_or_else(|| serde_json::json!({}));
1393                if !context.is_object() {
1394                    context = serde_json::json!({});
1395                }
1396                merge_json_object(&mut context, &req.context);
1397                merge_json_object(&mut context, &metadata);
1398                if let serde_json::Value::Object(ref mut m) = context {
1399                    m.insert("masked_tail".to_string(), serde_json::json!(masked_tail));
1400                }
1401
1402                let now = SystemTime::now()
1403                    .duration_since(UNIX_EPOCH)
1404                    .map(|d| d.as_millis() as i64)
1405                    .unwrap_or(0);
1406                let created_at = existing
1407                    .as_ref()
1408                    .map(|a| a.created_at)
1409                    .filter(|&c| c != 0)
1410                    .unwrap_or(now);
1411
1412                let account = IdentityAccount {
1413                    id: account_id.clone(),
1414                    name: req.name.clone(),
1415                    provider: req.provider.clone(),
1416                    kind: if req.kind.is_empty() { "api_key".to_string() } else { req.kind.clone() },
1417                    display_name: req.display_name.clone(),
1418                    secret_ref: SecretRef::Keychain {
1419                        service: crate::identity::secret_store::SERVICE.to_string(),
1420                        account: crate::identity::secret_store::account_key(&account_id),
1421                    },
1422                    context,
1423                    status,
1424                    created_at,
1425                    updated_at: now,
1426                };
1427                if let Err(e) = wstore.identity_upsert(&account) {
1428                    // DB write failed after the keychain write.
1429                    //  - New account: nothing references the secret yet, so
1430                    //    roll it back to avoid an orphan with no DB row.
1431                    //  - Replacement: the existing DB row still points at this
1432                    //    keychain entry. Deleting it would destroy the
1433                    //    previously-working credential; leave the (now
1434                    //    overwritten) secret in place — resolution still works
1435                    //    with the new key, and the user can retry to fix
1436                    //    metadata. So only roll back for new accounts.
1437                    if is_new {
1438                        let aid = account_id.clone();
1439                        if let Err(de) = tokio::task::spawn_blocking(move || {
1440                            crate::identity::secret_store::delete(&aid)
1441                        })
1442                        .await
1443                        .unwrap_or_else(|je| Err(format!("join: {je}")))
1444                        {
1445                            tracing::warn!(
1446                                target: "identity",
1447                                "rollback of orphaned keychain secret for {} failed: {de}",
1448                                account_id,
1449                            );
1450                        }
1451                    }
1452                    return Err(format!("account.key.verify: {e}"));
1453                }
1454                broker.publish(crate::backend::wps::WaveEvent {
1455                    event: "identityaccounts:changed".to_string(),
1456                    scopes: vec![],
1457                    sender: String::new(),
1458                    persist: 0,
1459                    data: None,
1460                });
1461                Ok(Some(serde_json::json!({
1462                    "valid": valid,
1463                    "accountId": account_id,
1464                    "maskedTail": account.context.get("masked_tail").cloned().unwrap_or_default(),
1465                    "status": account.status,
1466                    "metadata": account.context,
1467                })))
1468            })
1469        }),
1470    );
1471
1472    // ── Trust Center service OAuth (scaffold) ──
1473    // start: resolve config + client (gates on "not configured"), spawn the
1474    // flow, return session id + initial status. poll/cancel drive the rest.
1475    let oauth_wstore = state.wstore.clone();
1476    engine.register_handler(
1477        COMMAND_ACCOUNT_OAUTH_START,
1478        Box::new(move |data, _ctx| {
1479            let wstore = oauth_wstore.clone();
1480            Box::pin(async move {
1481                let req: OAuthStartReq = serde_json::from_value(data)
1482                    .map_err(|e| format!("account.oauth.start: {e}"))?;
1483                let byo = req.client_id.map(|cid| {
1484                    crate::identity::oauth_client::ByoCredentials {
1485                        client_id: cid,
1486                        client_secret: req.client_secret,
1487                    }
1488                });
1489                match crate::identity::oauth_client::start(&req.provider, req.name, byo, wstore) {
1490                    Ok((session_id, status)) => Ok(Some(serde_json::json!({
1491                        "sessionId": session_id,
1492                        "status": oauth_status_wire(&status),
1493                    }))),
1494                    // "not configured" / unknown provider surface as a clean
1495                    // error field, not an RPC failure, so the UI can show it.
1496                    Err(e) => Ok(Some(serde_json::json!({ "error": e }))),
1497                }
1498            })
1499        }),
1500    );
1501
1502    engine.register_handler(
1503        COMMAND_ACCOUNT_OAUTH_POLL,
1504        Box::new(move |data, _ctx| {
1505            Box::pin(async move {
1506                let req: OAuthSessionReq = serde_json::from_value(data)
1507                    .map_err(|e| format!("account.oauth.poll: {e}"))?;
1508                match crate::identity::oauth_client::manager().poll(&req.session_id) {
1509                    Some(s) => Ok(Some(oauth_status_wire(&s))),
1510                    None => Err(format!("account.oauth.poll: unknown session {}", req.session_id)),
1511                }
1512            })
1513        }),
1514    );
1515
1516    engine.register_handler(
1517        COMMAND_ACCOUNT_OAUTH_CANCEL,
1518        Box::new(move |data, _ctx| {
1519            Box::pin(async move {
1520                let req: OAuthSessionReq = serde_json::from_value(data)
1521                    .map_err(|e| format!("account.oauth.cancel: {e}"))?;
1522                let cancelled = crate::identity::oauth_client::manager().cancel(&req.session_id);
1523                Ok(Some(serde_json::json!({ "cancelled": cancelled })))
1524            })
1525        }),
1526    );
1527
1528    let wstore = state.wstore.clone();
1529    let broker = state.broker.clone();
1530    engine.register_handler(
1531        COMMAND_DELETE_IDENTITY_ACCOUNT,
1532        Box::new(move |data, _ctx| {
1533            let wstore = wstore.clone();
1534            let broker = broker.clone();
1535            Box::pin(async move {
1536                let cmd: CommandDeleteIdentityAccountData = serde_json::from_value(data)
1537                    .map_err(|e| format!("deleteidentityaccount: {e}"))?;
1538                // If this account stored its secret in the OS keychain, drop
1539                // it too so no orphaned credential survives the DB row.
1540                // `keyring` is blocking, so run it via spawn_blocking.
1541                if let Ok(Some(acct)) = wstore.identity_get(&cmd.id) {
1542                    if matches!(acct.secret_ref, SecretRef::Keychain { .. }) {
1543                        let aid = cmd.id.clone();
1544                        let res = tokio::task::spawn_blocking(move || {
1545                            crate::identity::secret_store::delete(&aid)
1546                        })
1547                        .await
1548                        .unwrap_or_else(|je| Err(format!("join: {je}")));
1549                        if let Err(e) = res {
1550                            tracing::warn!(target: "identity", "keychain delete for {} failed: {e}", cmd.id);
1551                        }
1552                    }
1553                }
1554                let deleted = wstore
1555                    .identity_delete(&cmd.id)
1556                    .map_err(|e| format!("deleteidentityaccount: {e}"))?;
1557                if deleted {
1558                    broker.publish(crate::backend::wps::WaveEvent {
1559                        event: "identityaccounts:changed".to_string(),
1560                        scopes: vec![],
1561                        sender: String::new(),
1562                        persist: 0,
1563                        data: None,
1564                    });
1565                }
1566                Ok(Some(json!({ "deleted": deleted })))
1567            })
1568        }),
1569    );
1570
1571    // ---- Agent ↔ Identity junction ----
1572
1573    let wstore = state.wstore.clone();
1574    let broker = state.broker.clone();
1575    engine.register_handler(
1576        COMMAND_LINK_AGENT_IDENTITY,
1577        Box::new(move |data, _ctx| {
1578            let wstore = wstore.clone();
1579            let broker = broker.clone();
1580            Box::pin(async move {
1581                let cmd: CommandLinkAgentIdentityData = serde_json::from_value(data)
1582                    .map_err(|e| format!("linkagentidentity: {e}"))?;
1583                wstore
1584                    .agent_identity_link(&cmd.agent_id, &cmd.account_id, &cmd.provider)
1585                    .map_err(|e| format!("linkagentidentity: {e}"))?;
1586                broker.publish(crate::backend::wps::WaveEvent {
1587                    event: format!("agentidentities:changed:{}", cmd.agent_id),
1588                    scopes: vec![],
1589                    sender: String::new(),
1590                    persist: 0,
1591                    data: None,
1592                });
1593                Ok(None)
1594            })
1595        }),
1596    );
1597
1598    let wstore = state.wstore.clone();
1599    let broker = state.broker.clone();
1600    engine.register_handler(
1601        COMMAND_UNLINK_AGENT_IDENTITY,
1602        Box::new(move |data, _ctx| {
1603            let wstore = wstore.clone();
1604            let broker = broker.clone();
1605            Box::pin(async move {
1606                let cmd: CommandUnlinkAgentIdentityData = serde_json::from_value(data)
1607                    .map_err(|e| format!("unlinkagentidentity: {e}"))?;
1608                let removed = wstore
1609                    .agent_identity_unlink(&cmd.agent_id, &cmd.provider)
1610                    .map_err(|e| format!("unlinkagentidentity: {e}"))?;
1611                if removed {
1612                    broker.publish(crate::backend::wps::WaveEvent {
1613                        event: format!("agentidentities:changed:{}", cmd.agent_id),
1614                        scopes: vec![],
1615                        sender: String::new(),
1616                        persist: 0,
1617                        data: None,
1618                    });
1619                }
1620                Ok(Some(json!({ "unlinked": removed })))
1621            })
1622        }),
1623    );
1624
1625    let wstore = state.wstore.clone();
1626    engine.register_handler(
1627        COMMAND_LIST_AGENT_IDENTITIES,
1628        Box::new(move |data, _ctx| {
1629            let wstore = wstore.clone();
1630            Box::pin(async move {
1631                let cmd: CommandListAgentIdentitiesData = serde_json::from_value(data)
1632                    .map_err(|e| format!("listagentidentities: {e}"))?;
1633                let rows = wstore
1634                    .agent_identity_list_for_agent(&cmd.agent_id)
1635                    .map_err(|e| format!("listagentidentities: {e}"))?;
1636                Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
1637            })
1638        }),
1639    );
1640
1641    // ---- Agent instance CRUD ----
1642
1643    let wstore = state.wstore.clone();
1644    engine.register_handler(
1645        COMMAND_LIST_AGENT_INSTANCES,
1646        Box::new(move |data, _ctx| {
1647            let wstore = wstore.clone();
1648            Box::pin(async move {
1649                let cmd: CommandListAgentInstancesData =
1650                    serde_json::from_value(data).unwrap_or_default();
1651                let rows = wstore
1652                    .instance_list(cmd.definition_id.as_deref(), cmd.status.as_deref())
1653                    .map_err(|e| format!("listagentinstances: {e}"))?;
1654                Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
1655            })
1656        }),
1657    );
1658
1659    let wstore = state.wstore.clone();
1660    engine.register_handler(
1661        COMMAND_GET_AGENT_INSTANCE,
1662        Box::new(move |data, _ctx| {
1663            let wstore = wstore.clone();
1664            Box::pin(async move {
1665                let cmd: CommandGetAgentInstanceData = serde_json::from_value(data)
1666                    .map_err(|e| format!("getagentinstance: {e}"))?;
1667                match wstore
1668                    .instance_get(&cmd.id)
1669                    .map_err(|e| format!("getagentinstance: {e}"))?
1670                {
1671                    Some(i) => Ok(Some(serde_json::to_value(&i).unwrap_or_default())),
1672                    None => Err(format!("getagentinstance: not found id={}", cmd.id)),
1673                }
1674            })
1675        }),
1676    );
1677
1678    let wstore = state.wstore.clone();
1679    let broker = state.broker.clone();
1680    engine.register_handler(
1681        COMMAND_CREATE_AGENT_INSTANCE,
1682        Box::new(move |data, _ctx| {
1683            let wstore = wstore.clone();
1684            let broker = broker.clone();
1685            Box::pin(async move {
1686                let cmd: CommandCreateAgentInstanceData = serde_json::from_value(data)
1687                    .map_err(|e| format!("createagentinstance: {e}"))?;
1688                let now = SystemTime::now()
1689                    .duration_since(UNIX_EPOCH)
1690                    .map(|d| d.as_millis() as i64)
1691                    .unwrap_or(0);
1692                let inst = AgentInstance {
1693                    id: uuid::Uuid::new_v4().to_string(),
1694                    definition_id: cmd.definition_id,
1695                    parent_instance_id: cmd.parent_instance_id,
1696                    block_id: cmd.block_id,
1697                    session_id: String::new(),
1698                    status: InstanceStatus::Running.as_str().to_string(),
1699                    github_context: String::new(),
1700                    started_at: now,
1701                    ended_at: 0,
1702                    created_at: now,
1703                    // PR-F.3: launch modal passes through Identity +
1704                    // Memory bundle picks. Empty string = blank
1705                    // singleton (no override; the resolver returns
1706                    // immediately on either "" or "blank").
1707                    identity_id: cmd.identity_id,
1708                    memory_id: cmd.memory_id,
1709                    // v8: named-agent continuation. instance_name +
1710                    // working_directory come from the launch-modal
1711                    // overrides via CommandCreateAgentInstanceData
1712                    // (added in the same spec). Empty string for
1713                    // legacy/ambient launches.
1714                    instance_name: cmd.instance_name.clone(),
1715                    working_directory: cmd.working_directory.clone(),
1716                    display_hidden: false,
1717                };
1718                wstore
1719                    .instance_create(&inst)
1720                    .map_err(|e| format!("createagentinstance: {e}"))?;
1721
1722                // Option E (PR 1 of 2) — stamp the agent-anchored
1723                // session zone reference onto the block meta. Every
1724                // block of this agent definition reads/writes through
1725                // `agent:<defId>:current`. Continuation is now
1726                // structural (same zone, different block) rather than
1727                // parametric (per-block snapshot copy + --continue).
1728                // See docs/specs/SPEC_CONTINUATION_SESSION_PERSISTENCE_2026_05_23.md.
1729                if !inst.block_id.is_empty()
1730                    && crate::backend::agent_session::is_valid_definition_id(&inst.definition_id)
1731                {
1732                    let zone = crate::backend::agent_session::agent_current_zone(
1733                        &inst.definition_id,
1734                    );
1735                    let mut meta_update = crate::backend::obj::MetaMapType::new();
1736                    meta_update.insert(
1737                        "agent:sessionZone".to_string(),
1738                        serde_json::json!(zone),
1739                    );
1740                    let oref_str = format!("block:{}", inst.block_id);
1741                    if let Err(e) = crate::server::service::update_object_meta(
1742                        &wstore, &oref_str, &meta_update,
1743                    ) {
1744                        // Non-fatal — the instance row is the source
1745                        // of truth, the meta stamp is a frontend
1746                        // convenience. Log + continue so the launch
1747                        // doesn't abort mid-flow.
1748                        tracing::warn!(
1749                            block_id = %inst.block_id,
1750                            definition_id = %inst.definition_id,
1751                            error = %e,
1752                            "createagentinstance: failed to stamp agent:sessionZone"
1753                        );
1754                    }
1755                }
1756
1757                broker.publish(crate::backend::wps::WaveEvent {
1758                    event: format!("agentinstances:changed:{}", inst.definition_id),
1759                    scopes: vec![],
1760                    sender: String::new(),
1761                    persist: 0,
1762                    data: None,
1763                });
1764                Ok(Some(serde_json::to_value(&inst).unwrap_or_default()))
1765            })
1766        }),
1767    );
1768
1769    let wstore = state.wstore.clone();
1770    let broker = state.broker.clone();
1771    engine.register_handler(
1772        COMMAND_UPDATE_AGENT_INSTANCE,
1773        Box::new(move |data, _ctx| {
1774            let wstore = wstore.clone();
1775            let broker = broker.clone();
1776            Box::pin(async move {
1777                let cmd: CommandUpdateAgentInstanceData = serde_json::from_value(data)
1778                    .map_err(|e| format!("updateagentinstance: {e}"))?;
1779                // Partial write — only the fields the command provided.
1780                // No fetch-and-merge: this used to `instance_get` the full
1781                // row to fill the unspecified fields, which was the sole
1782                // production caller needing `instance_get`'s transient
1783                // per-launch columns. The store builds a dynamic UPDATE
1784                // and returns the post-write row (for the event scope +
1785                // response) from the reload it already runs.
1786                // SPEC_UPDATEAGENTINSTANCE_PARTIAL_UPDATE_2026_05_29.md.
1787                let upd = crate::backend::storage::InstanceUpdate {
1788                    block_id: cmd.block_id,
1789                    session_id: cmd.session_id,
1790                    status: cmd.status,
1791                    github_context: cmd.github_context,
1792                    ended_at: cmd.ended_at,
1793                };
1794                let fresh = wstore
1795                    .instance_update_partial(&cmd.id, &upd)
1796                    .map_err(|e| format!("updateagentinstance: {e}"))?
1797                    .ok_or_else(|| format!("updateagentinstance: not found id={}", cmd.id))?;
1798                broker.publish(crate::backend::wps::WaveEvent {
1799                    event: format!("agentinstances:changed:{}", fresh.definition_id),
1800                    scopes: vec![],
1801                    sender: String::new(),
1802                    persist: 0,
1803                    data: None,
1804                });
1805                Ok(Some(serde_json::to_value(&fresh).unwrap_or_default()))
1806            })
1807        }),
1808    );
1809
1810    let wstore = state.wstore.clone();
1811    let broker = state.broker.clone();
1812    engine.register_handler(
1813        COMMAND_DELETE_AGENT_INSTANCE,
1814        Box::new(move |data, _ctx| {
1815            let wstore = wstore.clone();
1816            let broker = broker.clone();
1817            Box::pin(async move {
1818                let cmd: CommandDeleteAgentInstanceData = serde_json::from_value(data)
1819                    .map_err(|e| format!("deleteagentinstance: {e}"))?;
1820                // Read the row first so we can emit a scoped event after.
1821                let definition_id = wstore
1822                    .instance_get(&cmd.id)
1823                    .map_err(|e| format!("deleteagentinstance: {e}"))?
1824                    .map(|i| i.definition_id);
1825                let deleted = wstore
1826                    .instance_delete(&cmd.id)
1827                    .map_err(|e| format!("deleteagentinstance: {e}"))?;
1828                if let Some(def_id) = definition_id.filter(|_| deleted) {
1829                    broker.publish(crate::backend::wps::WaveEvent {
1830                        event: format!("agentinstances:changed:{}", def_id),
1831                        scopes: vec![],
1832                        sender: String::new(),
1833                        persist: 0,
1834                        data: None,
1835                    });
1836                }
1837                Ok(Some(json!({ "deleted": deleted })))
1838            })
1839        }),
1840    );
1841
1842    // ---- v8: named agent continuation ----
1843
1844    // listnamedagents — powers the launch modal's "Continue agent"
1845    // dropdown. Joins instance rows with the definition / identity /
1846    // memory bundle names so the frontend renders without follow-ups.
1847    let wstore = state.wstore.clone();
1848    engine.register_handler(
1849        COMMAND_LIST_NAMED_AGENTS,
1850        Box::new(move |data, _ctx| {
1851            let wstore = wstore.clone();
1852            Box::pin(async move {
1853                let cmd: CommandListNamedAgentsData =
1854                    serde_json::from_value(data).unwrap_or_default();
1855                let limit = if cmd.limit == 0 {
1856                    200
1857                } else {
1858                    cmd.limit.min(1000)
1859                };
1860                // Resolve bundle names once per response. With ≤200
1861                // rows and typical bundle counts in the low dozens,
1862                // a linear lookup on cached lists beats per-row
1863                // round-trips through the store.
1864                let defs = wstore
1865                    .agent_def_list()
1866                    .map_err(|e| format!("listnamedagents: agent_def_list: {e}"))?;
1867                let identities = wstore
1868                    .bundle_identity_list()
1869                    .map_err(|e| format!("listnamedagents: bundle_identity_list: {e}"))?;
1870                let memories = wstore
1871                    .bundle_memory_list()
1872                    .map_err(|e| format!("listnamedagents: bundle_memory_list: {e}"))?;
1873
1874                // PR B — read from the cross-version registry when
1875                // it's available. Falls back to SQLite when the
1876                // registry couldn't be resolved at startup (CI / odd
1877                // environments). SQLite remains authoritative for
1878                // PR B (parallel-write is still active); the choice
1879                // here just affects which surface gets surfaced.
1880                let rows: Vec<NamedAgentRow> = match wstore.shared_agent_registry() {
1881                    Some(reg) => {
1882                        // Re-join relative working_dir against the CURRENT
1883                        // channel's agents dir (symmetric with the write
1884                        // mirror), not the registry's own parent — P0.3
1885                        // re-roots the registry out of channels/<ch>/agents/.
1886                        let agents_root = wstore.registry_agents_base();
1887                        let mut records = reg
1888                            .list_active()
1889                            .map_err(|e| format!("listnamedagents: registry: {e}"))?;
1890                        if let Some(def_filter) = cmd.definition_id.as_deref() {
1891                            records.retain(|r| r.data.definition_id == def_filter);
1892                        }
1893                        records.sort_by(|a, b| {
1894                            b.data
1895                                .last_launched_at_ms
1896                                .cmp(&a.data.last_launched_at_ms)
1897                        });
1898                        records.truncate(limit);
1899                        // Pre-fetch all candidate same-version rows
1900                        // ONCE so enrichment doesn't issue N+1 queries.
1901                        // Indexed by instance_id; rows that aren't in
1902                        // current SQLite fall through to sentinels.
1903                        // Registry enrichment: keep head-of-chain
1904                        // only. The registry mirror itself excludes
1905                        // continuations (see
1906                        // `registry_upsert_if_named`), so the SQLite
1907                        // side must match — else under the `limit`
1908                        // truncation continuation rows displace
1909                        // registry-head rows and the merge-by-id
1910                        // enrichment misses, silently downgrading
1911                        // running-state badges and block_id_hints to
1912                        // "available" / empty.
1913                        let sqlite_rows: Vec<AgentInstance> = wstore
1914                            .instance_list_named(
1915                                records.len().max(1),
1916                                cmd.definition_id.as_deref(),
1917                                /* identity_id */ None,
1918                                /* include_continuations */ false,
1919                            )
1920                            .unwrap_or_default();
1921                        let sqlite_by_id: std::collections::HashMap<&str, &AgentInstance> =
1922                            sqlite_rows.iter().map(|i| (i.id.as_str(), i)).collect();
1923                        records
1924                            .into_iter()
1925                            .map(|rec| {
1926                                let d = rec.data;
1927                                let def = defs.iter().find(|x| x.id == d.definition_id);
1928                                let identity_id_str =
1929                                    d.identity_id.clone().unwrap_or_default();
1930                                let memory_id_str = d.memory_id.clone().unwrap_or_default();
1931                                let identity_name = if identity_id_str.is_empty() {
1932                                    "(ambient creds)".to_string()
1933                                } else {
1934                                    identities
1935                                        .iter()
1936                                        .find(|i| i.id == identity_id_str)
1937                                        .map(|i| i.name.clone())
1938                                        .unwrap_or_else(|| "(missing identity)".to_string())
1939                                };
1940                                let memory_name = if memory_id_str.is_empty() {
1941                                    "(vanilla CLI)".to_string()
1942                                } else {
1943                                    memories
1944                                        .iter()
1945                                        .find(|m| m.id == memory_id_str)
1946                                        .map(|m| m.name.clone())
1947                                        .unwrap_or_else(|| "(missing memory)".to_string())
1948                                };
1949                                // Reconstruct the absolute working_directory.
1950                                // v3 records carry their SOURCE channel agents
1951                                // dir, so a row from another channel resolves
1952                                // to its real workspace; legacy (v1/v2) records
1953                                // fall back to the current channel base — the
1954                                // pre-P0.4 behavior (correct for same-channel
1955                                // rows, which is all v1/v2 could represent).
1956                                let working_directory = if let Some(src) =
1957                                    d.source_agents_base.as_deref()
1958                                {
1959                                    std::path::Path::new(src)
1960                                        .join(&d.working_dir)
1961                                        .to_string_lossy()
1962                                        .to_string()
1963                                } else {
1964                                    match agents_root.as_ref() {
1965                                        Some(root) => root
1966                                            .join(&d.working_dir)
1967                                            .to_string_lossy()
1968                                            .to_string(),
1969                                        None => d.working_dir.clone(),
1970                                    }
1971                                };
1972                                // Same-version enrichment: if this id
1973                                // also exists in current SQLite, the
1974                                // row carries runtime state (block_id
1975                                // for focus-existing-pane, status,
1976                                // ended_at) that the registry
1977                                // intentionally doesn't track.
1978                                // Cross-version rows fall through with
1979                                // sentinel "available" status and
1980                                // empty block_id_hint.
1981                                let (block_id_hint, status, ended_at) =
1982                                    match sqlite_by_id.get(d.instance_id.as_str()) {
1983                                        Some(inst) => (
1984                                            inst.block_id.clone(),
1985                                            inst.status.clone(),
1986                                            inst.ended_at,
1987                                        ),
1988                                        None => (String::new(), "available".to_string(), 0),
1989                                    };
1990                                NamedAgentRow {
1991                                    instance_id: d.instance_id,
1992                                    instance_name: d.instance_name,
1993                                    definition_id: d.definition_id.clone(),
1994                                    definition_name: def
1995                                        .map(|x| x.name.clone())
1996                                        .unwrap_or_else(|| "(missing definition)".to_string()),
1997                                    provider: def
1998                                        .map(|x| x.provider.clone())
1999                                        .unwrap_or_default(),
2000                                    working_directory,
2001                                    identity_id: identity_id_str,
2002                                    identity_name,
2003                                    memory_id: memory_id_str,
2004                                    memory_name,
2005                                    started_at: d.last_launched_at_ms,
2006                                    ended_at,
2007                                    status,
2008                                    block_id_hint,
2009                                }
2010                            })
2011                            .collect()
2012                    }
2013                    None => {
2014                        // No-registry fallback: drives the launch
2015                        // modal's "Continue agent" dropdown directly.
2016                        // One entry per chain root, mirroring the
2017                        // registry path's semantics.
2018                        let instances = wstore
2019                            .instance_list_named(
2020                                limit,
2021                                cmd.definition_id.as_deref(),
2022                                /* identity_id */ None,
2023                                /* include_continuations */ false,
2024                            )
2025                            .map_err(|e| format!("listnamedagents: {e}"))?;
2026                        instances
2027                            .into_iter()
2028                            .map(|inst| {
2029                                let def = defs.iter().find(|d| d.id == inst.definition_id);
2030                                let identity_name = if inst.identity_id.is_empty() {
2031                                    "(ambient creds)".to_string()
2032                                } else {
2033                                    identities
2034                                        .iter()
2035                                        .find(|i| i.id == inst.identity_id)
2036                                        .map(|i| i.name.clone())
2037                                        .unwrap_or_else(|| "(missing identity)".to_string())
2038                                };
2039                                let memory_name = if inst.memory_id.is_empty() {
2040                                    "(vanilla CLI)".to_string()
2041                                } else {
2042                                    memories
2043                                        .iter()
2044                                        .find(|m| m.id == inst.memory_id)
2045                                        .map(|m| m.name.clone())
2046                                        .unwrap_or_else(|| "(missing memory)".to_string())
2047                                };
2048                                NamedAgentRow {
2049                                    instance_id: inst.id,
2050                                    instance_name: inst.instance_name,
2051                                    definition_id: inst.definition_id.clone(),
2052                                    definition_name: def
2053                                        .map(|d| d.name.clone())
2054                                        .unwrap_or_else(|| "(missing definition)".to_string()),
2055                                    provider: def
2056                                        .map(|d| d.provider.clone())
2057                                        .unwrap_or_default(),
2058                                    working_directory: inst.working_directory,
2059                                    identity_id: inst.identity_id,
2060                                    identity_name,
2061                                    memory_id: inst.memory_id,
2062                                    memory_name,
2063                                    started_at: inst.started_at,
2064                                    ended_at: inst.ended_at,
2065                                    status: inst.status,
2066                                    block_id_hint: inst.block_id,
2067                                }
2068                            })
2069                            .collect()
2070                    }
2071                };
2072
2073                Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
2074            })
2075        }),
2076    );
2077
2078    // hidenamedagent — soft-delete (sets display_hidden = 1) so the
2079    // row disappears from the dropdown. Working dir stays on disk.
2080    let wstore = state.wstore.clone();
2081    let broker = state.broker.clone();
2082    engine.register_handler(
2083        COMMAND_HIDE_NAMED_AGENT,
2084        Box::new(move |data, _ctx| {
2085            let wstore = wstore.clone();
2086            let broker = broker.clone();
2087            Box::pin(async move {
2088                let cmd: CommandHideNamedAgentData = serde_json::from_value(data)
2089                    .map_err(|e| format!("hidenamedagent: {e}"))?;
2090                let hidden = wstore
2091                    .instance_set_hidden(&cmd.id, true)
2092                    .map_err(|e| format!("hidenamedagent: {e}"))?;
2093                if hidden {
2094                    broker.publish(crate::backend::wps::WaveEvent {
2095                        event: "namedagents:changed".to_string(),
2096                        scopes: vec![],
2097                        sender: String::new(),
2098                        persist: 0,
2099                        data: None,
2100                    });
2101                }
2102                Ok(Some(json!({ "hidden": hidden })))
2103            })
2104        }),
2105    );
2106
2107    // ---- Recent sessions (cascade follow-up 2026-05-23) ----
2108    //
2109    // listrecentsessions — joins `db_agent_instances` with the
2110    // filestore `output.state.json` snapshot for each instance's
2111    // block_id_hint, producing a preview + node count so the
2112    // AgentPicker can show actual conversation context instead of just
2113    // metadata. Sort key is the snapshot modts (last activity)
2114    // descending; rows without a snapshot fall back to the instance
2115    // started_at and are de-prioritized. Cap at 20 rows.
2116    //
2117    // The reattach mechanism is the existing continuation flow:
2118    // continueOfInstanceId + workDirOverride (see PR #977). This RPC
2119    // is a more discoverable surface for finding sessions to continue
2120    // — particularly orphaned ones whose pane crashed.
2121    let wstore = state.wstore.clone();
2122    let filestore = state.filestore.clone();
2123    engine.register_handler(
2124        COMMAND_LIST_RECENT_SESSIONS,
2125        Box::new(move |data, _ctx| {
2126            let wstore = wstore.clone();
2127            let filestore = filestore.clone();
2128            Box::pin(async move {
2129                let cmd: CommandListRecentSessionsData =
2130                    serde_json::from_value(data).unwrap_or_default();
2131                let limit = if cmd.limit == 0 {
2132                    20
2133                } else {
2134                    cmd.limit.min(100)
2135                };
2136                // Pull up to ~10x the requested cap so we can post-
2137                // filter by snapshot presence + identity_id without
2138                // running out of candidates. 10x is a safety margin
2139                // and stays well inside the 200 default of
2140                // instance_list_named.
2141                let raw_limit = (limit * 10).max(50).min(500);
2142
2143                // Identity filter is pushed INTO `instance_list_named`
2144                // (codex P2 #3 on PR #1096): when a chain has
2145                // continuations with different identity bundles, the
2146                // ranking must run on identity-matching rows so the
2147                // newest match wins. Post-query filtering would drop
2148                // the chain entirely if the newest row used a
2149                // different identity, even when older rows match.
2150                let identity_filter = cmd
2151                    .identity_id
2152                    .as_deref()
2153                    .map(|s| s.trim())
2154                    .filter(|s| !s.is_empty());
2155
2156                // "My Agents" sources from the cross-version REGISTRY when it's
2157                // available, so agents created in ANY build / channel / version
2158                // appear here — not just this instance's local SQLite sessions
2159                // (the live mirror, registry_mirror.rs, keeps the registry current
2160                // for global workspaces). Falls back to local SQLite when the
2161                // registry couldn't be resolved (CI / odd envs). Cross-channel rows
2162                // arrive as synthetic instances (no live block); the per-instance
2163                // snapshot enrichment below lights up the ones that ALSO ran here.
2164                let instances: Vec<AgentInstance> = match wstore.shared_agent_registry() {
2165                    Some(reg) => {
2166                        let agents_root = wstore.registry_agents_base();
2167                        let mut records = reg
2168                            .list_active()
2169                            .map_err(|e| format!("listrecentsessions: registry: {e}"))?;
2170                        if let Some(idf) = identity_filter {
2171                            records.retain(|r| r.data.identity_id.as_deref() == Some(idf));
2172                        }
2173                        // Dedup by (definition_id, instance_name) keeping the newest
2174                        // launch — the registry read path lacks SQLite's chain-root
2175                        // collapse, so two fresh heads of one logical agent would
2176                        // otherwise double up. Sort newest-first within each group,
2177                        // collapse, then re-sort by recency.
2178                        records.sort_by(|a, b| {
2179                            a.data
2180                                .definition_id
2181                                .cmp(&b.data.definition_id)
2182                                .then_with(|| a.data.instance_name.cmp(&b.data.instance_name))
2183                                .then_with(|| {
2184                                    b.data.last_launched_at_ms.cmp(&a.data.last_launched_at_ms)
2185                                })
2186                        });
2187                        records.dedup_by(|a, b| {
2188                            a.data.definition_id == b.data.definition_id
2189                                && a.data.instance_name == b.data.instance_name
2190                        });
2191                        records.sort_by(|a, b| {
2192                            b.data.last_launched_at_ms.cmp(&a.data.last_launched_at_ms)
2193                        });
2194                        records.truncate(raw_limit);
2195                        // Local agents in PICKER mode (include_continuations=true):
2196                        // collapses each chain to one row AND surfaces orphan
2197                        // continuations (head hard-deleted) as their own root, so
2198                        // they don't vanish from "My Agents" (reagent P2). Indexed by
2199                        // (definition_id, instance_name) — the SAME key the registry
2200                        // dedup uses — keeping the newest, so overlay AND the
2201                        // local-only append agree on identity (reagent P1).
2202                        let local = wstore
2203                            .instance_list_named(raw_limit, None, identity_filter, true)
2204                            .unwrap_or_default();
2205                        let mut local_by_key: std::collections::HashMap<
2206                            (String, String),
2207                            AgentInstance,
2208                        > = std::collections::HashMap::new();
2209                        for li in local {
2210                            let key = (li.definition_id.clone(), li.instance_name.clone());
2211                            match local_by_key.get(&key) {
2212                                Some(e) if e.started_at >= li.started_at => {}
2213                                _ => {
2214                                    local_by_key.insert(key, li);
2215                                }
2216                            }
2217                        }
2218                        let mut out: Vec<AgentInstance> = records
2219                            .into_iter()
2220                            .map(|rec| {
2221                                let d = rec.data;
2222                                let key = (d.definition_id.clone(), d.instance_name.clone());
2223                                if let Some(li) = local_by_key.get(&key) {
2224                                    return li.clone();
2225                                }
2226                                // Reconstruct the absolute workdir from the record's
2227                                // source base (v3) or the current channel (legacy).
2228                                let working_directory = match d.source_agents_base.as_deref() {
2229                                    Some(src) => std::path::Path::new(src)
2230                                        .join(&d.working_dir)
2231                                        .to_string_lossy()
2232                                        .to_string(),
2233                                    None => match agents_root.as_ref() {
2234                                        Some(root) => root
2235                                            .join(&d.working_dir)
2236                                            .to_string_lossy()
2237                                            .to_string(),
2238                                        None => d.working_dir.clone(),
2239                                    },
2240                                };
2241                                AgentInstance {
2242                                    id: d.instance_id,
2243                                    definition_id: d.definition_id,
2244                                    parent_instance_id: String::new(),
2245                                    block_id: String::new(),
2246                                    session_id: d.session_id.unwrap_or_default(),
2247                                    status: "available".to_string(),
2248                                    github_context: String::new(),
2249                                    started_at: d.last_launched_at_ms,
2250                                    ended_at: 0,
2251                                    created_at: d.created_at_ms,
2252                                    identity_id: d.identity_id.unwrap_or_default(),
2253                                    memory_id: d.memory_id.unwrap_or_default(),
2254                                    instance_name: d.instance_name,
2255                                    working_directory,
2256                                    display_hidden: false,
2257                                }
2258                            })
2259                            .collect();
2260                        // APPEND local-only agents — those whose (definition_id,
2261                        // instance_name) no registry record represents (created
2262                        // before the live mirror could register them, or orphan
2263                        // continuations). Keyed identically to the dedup, so a deduped
2264                        // agent's local head never re-appears as a duplicate row.
2265                        let have_keys: std::collections::HashSet<(String, String)> = out
2266                            .iter()
2267                            .map(|i| (i.definition_id.clone(), i.instance_name.clone()))
2268                            .collect();
2269                        for (key, li) in local_by_key {
2270                            if !have_keys.contains(&key) {
2271                                out.push(li);
2272                            }
2273                        }
2274                        out
2275                    }
2276                    None => wstore
2277                        .instance_list_named(raw_limit, None, identity_filter, true)
2278                        .map_err(|e| format!("listrecentsessions: {e}"))?,
2279                };
2280
2281                let defs = wstore
2282                    .agent_def_list()
2283                    .map_err(|e| format!("listrecentsessions: defs: {e}"))?;
2284                let identities = wstore
2285                    .bundle_identity_list()
2286                    .map_err(|e| format!("listrecentsessions: identities: {e}"))?;
2287                let memories = wstore
2288                    .bundle_memory_list()
2289                    .map_err(|e| format!("listrecentsessions: memories: {e}"))?;
2290
2291                // Build rows. Hits filestore once per instance; with
2292                // raw_limit ≤ 500 and stat() being a single indexed
2293                // SQLite query, the per-call cost is dominated by
2294                // the eventual snapshot read for the top-20.
2295                let mut rows: Vec<RecentSessionRow> = Vec::with_capacity(instances.len());
2296                for inst in instances {
2297                    let def = defs.iter().find(|d| d.id == inst.definition_id);
2298                    let identity_name = if inst.identity_id.is_empty() {
2299                        "(ambient creds)".to_string()
2300                    } else {
2301                        identities
2302                            .iter()
2303                            .find(|i| i.id == inst.identity_id)
2304                            .map(|i| i.name.clone())
2305                            .unwrap_or_else(|| "(missing identity)".to_string())
2306                    };
2307                    let memory_name = if inst.memory_id.is_empty() {
2308                        "(vanilla CLI)".to_string()
2309                    } else {
2310                        memories
2311                            .iter()
2312                            .find(|m| m.id == inst.memory_id)
2313                            .map(|m| m.name.clone())
2314                            .unwrap_or_else(|| "(missing memory)".to_string())
2315                    };
2316
2317                    // Stat first (cheap) — gives us the modts for
2318                    // sorting. Only fetch the full content if the
2319                    // snapshot exists.
2320                    let (has_snapshot, last_active_at, preview, node_count) =
2321                        if inst.block_id.is_empty() {
2322                            (false, inst.started_at, String::new(), 0usize)
2323                        } else {
2324                            match filestore.stat(&inst.block_id, "output.state.json") {
2325                                Ok(Some(file)) => {
2326                                    let modts = if file.modts > 0 {
2327                                        file.modts
2328                                    } else {
2329                                        inst.started_at
2330                                    };
2331                                    let (preview, node_count) = read_session_preview(
2332                                        &filestore,
2333                                        &inst.block_id,
2334                                    );
2335                                    (true, modts, preview, node_count)
2336                                }
2337                                _ => (false, inst.started_at, String::new(), 0usize),
2338                            }
2339                        };
2340
2341                    rows.push(RecentSessionRow {
2342                        instance_id: inst.id,
2343                        instance_name: inst.instance_name,
2344                        definition_id: inst.definition_id.clone(),
2345                        definition_name: def
2346                            .map(|d| d.name.clone())
2347                            .unwrap_or_else(|| "(missing definition)".to_string()),
2348                        provider: def.map(|d| d.provider.clone()).unwrap_or_default(),
2349                        working_directory: inst.working_directory,
2350                        identity_id: inst.identity_id,
2351                        identity_name,
2352                        memory_id: inst.memory_id,
2353                        memory_name,
2354                        block_id_hint: inst.block_id,
2355                        // Surface the CLI-captured session id so the
2356                        // picker reattach can `--resume <sid>` on the
2357                        // FIRST turn of the new block. Without this
2358                        // the new subprocess starts a fresh session
2359                        // and the CLI re-injects the startup context.
2360                        session_id: inst.session_id,
2361                        preview,
2362                        node_count,
2363                        last_active_at,
2364                        has_snapshot,
2365                        agent_created_at: def.map(|d| d.created_at).unwrap_or(0),
2366                        started_at: inst.started_at,
2367                        agent_type: def.map(|d| d.agent_type.clone()).unwrap_or_default(),
2368                    });
2369                }
2370
2371                // Sort: rows with a snapshot first (descending by
2372                // modts), then no-snapshot rows by started_at desc.
2373                // This keeps live conversations at the top while
2374                // still surfacing legacy rows.
2375                rows.sort_by(|a, b| match (a.has_snapshot, b.has_snapshot) {
2376                    (true, true) | (false, false) => {
2377                        b.last_active_at.cmp(&a.last_active_at)
2378                    }
2379                    (true, false) => std::cmp::Ordering::Less,
2380                    (false, true) => std::cmp::Ordering::Greater,
2381                });
2382                rows.truncate(limit);
2383
2384                Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
2385            })
2386        }),
2387    );
2388
2389    // ---- Definition fork ----
2390
2391    let wstore = state.wstore.clone();
2392    let broker = state.broker.clone();
2393    engine.register_handler(
2394        COMMAND_FORK_AGENT_DEFINITION,
2395        Box::new(move |data, _ctx| {
2396            let wstore = wstore.clone();
2397            let broker = broker.clone();
2398            Box::pin(async move {
2399                let cmd: CommandForkAgentDefinitionData = serde_json::from_value(data)
2400                    .map_err(|e| format!("forkagentdefinition: {e}"))?;
2401
2402                // Find the source definition by id.
2403                let all_defs = wstore
2404                    .agent_def_list()
2405                    .map_err(|e| format!("forkagentdefinition: {e}"))?;
2406                let source = all_defs
2407                    .iter()
2408                    .find(|a| a.id == cmd.source_id)
2409                    .cloned()
2410                    .ok_or_else(|| format!("forkagentdefinition: source not found: {}", cmd.source_id))?;
2411
2412                // Build a new definition that shares the source's content but
2413                // has a fresh id/slug and records the lineage. Seed-bit is
2414                // cleared — forks are always user-owned, not built-in.
2415                let now = SystemTime::now()
2416                    .duration_since(UNIX_EPOCH)
2417                    .map(|d| d.as_millis() as i64)
2418                    .unwrap_or(0);
2419
2420                // branch_label is the fork's full display name when provided.
2421                // When empty, auto-generate "Name #N" based on existing fork count.
2422                let fork_name = if cmd.branch_label.is_empty() {
2423                    let existing_fork_count = all_defs
2424                        .iter()
2425                        .filter(|a| a.parent_id == cmd.source_id && a.is_seeded == 0)
2426                        .count();
2427                    format!("{} #{}", source.name, existing_fork_count + 2)
2428                } else {
2429                    cmd.branch_label.clone()
2430                };
2431                let branch_label = if cmd.branch_label.is_empty() {
2432                    fork_name.clone()
2433                } else {
2434                    cmd.branch_label.clone()
2435                };
2436                let branch_slug_part = crate::backend::storage::store::derive_slug(&branch_label);
2437                let mut fork = AgentDefinition {
2438                    id: uuid::Uuid::new_v4().to_string(),
2439                    // Empty slug → agent_def_insert derives + resolves collisions.
2440                    slug: format!("{}-{}", source.slug, branch_slug_part),
2441                    name: fork_name,
2442                    icon: source.icon.clone(),
2443                    provider: source.provider.clone(),
2444                    description: source.description.clone(),
2445                    working_directory: String::new(), // force re-resolve via agentmuxHome()
2446                    shell: source.shell.clone(),
2447                    provider_flags: source.provider_flags.clone(),
2448                    auto_start: 0, // forks don't auto-start; explicit launch only
2449                    restart_on_crash: source.restart_on_crash,
2450                    idle_timeout_minutes: source.idle_timeout_minutes,
2451                    created_at: now,
2452                    agent_type: source.agent_type.clone(),
2453                    environment: source.environment.clone(),
2454                    agent_bus_id: String::new(), // fresh bus id so broadcasts don't cross
2455                    is_seeded: 0,
2456                    accounts: String::new(),
2457                    parent_id: source.id.clone(),
2458                    branch_label: branch_label.clone(),
2459                    updated_at: now,
2460                    user_hidden: 0,
2461                    // Forks inherit container config from source so forked container agents
2462                    // retain their image and volumes.
2463                    container_image: source.container_image.clone(),
2464                    container_volumes: source.container_volumes.clone(),
2465                    container_name: String::new(),
2466                };
2467                wstore
2468                    .agent_def_insert(&mut fork)
2469                    .map_err(|e| format!("forkagentdefinition: {e}"))?;
2470
2471                // Deep-copy content blobs + skills from source. Cascade foreign
2472                // keys on the source are unaffected — we're copying out, not
2473                // moving.
2474                let source_contents = wstore
2475                    .agent_content_get_all(&source.id)
2476                    .map_err(|e| format!("forkagentdefinition content: {e}"))?;
2477                for c in source_contents {
2478                    let new_content = AgentContent {
2479                        agent_id: fork.id.clone(),
2480                        content_type: c.content_type,
2481                        content: c.content,
2482                        updated_at: now,
2483                    };
2484                    wstore
2485                        .agent_content_set(&new_content)
2486                        .map_err(|e| format!("forkagentdefinition content: {e}"))?;
2487                }
2488                let source_skills = wstore
2489                    .agent_skill_list(&source.id)
2490                    .map_err(|e| format!("forkagentdefinition skills: {e}"))?;
2491                for s in source_skills {
2492                    let new_skill = AgentSkill {
2493                        id: uuid::Uuid::new_v4().to_string(),
2494                        agent_id: fork.id.clone(),
2495                        name: s.name,
2496                        trigger: s.trigger,
2497                        skill_type: s.skill_type,
2498                        description: s.description,
2499                        content: s.content,
2500                        created_at: now,
2501                    };
2502                    wstore
2503                        .agent_skill_insert(&new_skill)
2504                        .map_err(|e| format!("forkagentdefinition skill: {e}"))?;
2505                }
2506
2507                broker.publish(crate::backend::wps::WaveEvent {
2508                    event: "agents:changed".to_string(),
2509                    scopes: vec![],
2510                    sender: String::new(),
2511                    persist: 0,
2512                    data: None,
2513                });
2514
2515                Ok(Some(serde_json::to_value(&fork).unwrap_or_default()))
2516            })
2517        }),
2518    );
2519
2520    // ---- Definition fork suggest (read-only — no mutation) ----
2521
2522    let wstore_sug = state.wstore.clone();
2523    engine.register_handler(
2524        COMMAND_FORK_AGENT_DEFINITION_SUGGEST,
2525        Box::new(move |data, _ctx| {
2526            let wstore = wstore_sug.clone();
2527            Box::pin(async move {
2528                let cmd: CommandForkAgentDefinitionSuggestData = serde_json::from_value(data)
2529                    .map_err(|e| format!("forkagentdefinitionsuggest: {e}"))?;
2530
2531                let all = wstore
2532                    .agent_def_list()
2533                    .map_err(|e| format!("forkagentdefinitionsuggest: {e}"))?;
2534                let source = all
2535                    .iter()
2536                    .find(|a| a.id == cmd.source_id)
2537                    .ok_or_else(|| format!("forkagentdefinitionsuggest: source not found: {}", cmd.source_id))?;
2538
2539                let existing_fork_count = all
2540                    .iter()
2541                    .filter(|a| a.parent_id == cmd.source_id && a.is_seeded == 0)
2542                    .count();
2543                let suggested_label = format!("{} #{}", source.name, existing_fork_count + 2);
2544
2545                let result = ForkAgentDefinitionSuggestResult { suggested_label };
2546                Ok(Some(serde_json::to_value(&result).unwrap_or_default()))
2547            })
2548        }),
2549    );
2550
2551    register_agent_session_handlers(engine, state);
2552    register_v7_handlers(engine, state);
2553}
2554
2555/// Option E (PR 1 of 2) — agent-anchored session zone RPCs.
2556///
2557/// These commands read/write the per-agent FileStore zone
2558/// `agent:<definition_id>:current` and the per-archive zones
2559/// `agent:<definition_id>:archive:<ts_ms>`. Session is bound to the
2560/// agent definition, NOT the identity bundle — see the spec.
2561fn register_agent_session_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
2562    // ---- agent:session:read ----
2563    let filestore = state.filestore.clone();
2564    engine.register_handler(
2565        COMMAND_AGENT_SESSION_READ,
2566        Box::new(move |data, _ctx| {
2567            let filestore = filestore.clone();
2568            Box::pin(async move {
2569                let cmd: CommandAgentSessionReadData = serde_json::from_value(data)
2570                    .map_err(|e| format!("agent:session:read: {e}"))?;
2571                let (content, modts) =
2572                    crate::backend::agent_session::read_session_state(&filestore, &cmd.definition_id)
2573                        .map_err(|e| format!("agent:session:read: {e}"))?;
2574                Ok(Some(
2575                    serde_json::to_value(&AgentSessionReadResult { content, modts })
2576                        .unwrap_or_default(),
2577                ))
2578            })
2579        }),
2580    );
2581
2582    // ---- agent:session:write_state ----
2583    let filestore = state.filestore.clone();
2584    engine.register_handler(
2585        COMMAND_AGENT_SESSION_WRITE_STATE,
2586        Box::new(move |data, _ctx| {
2587            let filestore = filestore.clone();
2588            Box::pin(async move {
2589                let cmd: CommandAgentSessionWriteStateData = serde_json::from_value(data)
2590                    .map_err(|e| format!("agent:session:write_state: {e}"))?;
2591                let bytes = cmd.content.as_bytes();
2592                let bytes_written = bytes.len() as u64;
2593                crate::backend::agent_session::write_session_state(
2594                    &filestore,
2595                    &cmd.definition_id,
2596                    bytes,
2597                )
2598                .map_err(|e| format!("agent:session:write_state: {e}"))?;
2599                Ok(Some(
2600                    serde_json::to_value(&AgentSessionWriteStateResult { bytes_written })
2601                        .unwrap_or_default(),
2602                ))
2603            })
2604        }),
2605    );
2606
2607    // ---- agent:session:append_output ----
2608    let filestore = state.filestore.clone();
2609    engine.register_handler(
2610        COMMAND_AGENT_SESSION_APPEND_OUTPUT,
2611        Box::new(move |data, _ctx| {
2612            let filestore = filestore.clone();
2613            Box::pin(async move {
2614                let cmd: CommandAgentSessionAppendOutputData = serde_json::from_value(data)
2615                    .map_err(|e| format!("agent:session:append_output: {e}"))?;
2616                let bytes_written = crate::backend::agent_session::append_session_output(
2617                    &filestore,
2618                    &cmd.definition_id,
2619                    &cmd.line,
2620                )
2621                .map_err(|e| format!("agent:session:append_output: {e}"))?;
2622                Ok(Some(
2623                    serde_json::to_value(&AgentSessionAppendOutputResult { bytes_written })
2624                        .unwrap_or_default(),
2625                ))
2626            })
2627        }),
2628    );
2629
2630    // ---- agent:session:archive ----
2631    let filestore = state.filestore.clone();
2632    engine.register_handler(
2633        COMMAND_AGENT_SESSION_ARCHIVE,
2634        Box::new(move |data, _ctx| {
2635            let filestore = filestore.clone();
2636            Box::pin(async move {
2637                let cmd: CommandAgentSessionArchiveData = serde_json::from_value(data)
2638                    .map_err(|e| format!("agent:session:archive: {e}"))?;
2639                let result =
2640                    crate::backend::agent_session::archive_session(&filestore, &cmd.definition_id)
2641                        .map_err(|e| format!("agent:session:archive: {e}"))?;
2642                let (archive_zoneid, archived_at_ms) = match result {
2643                    Some((z, ts)) => (z, ts),
2644                    None => (String::new(), 0),
2645                };
2646                Ok(Some(
2647                    serde_json::to_value(&AgentSessionArchiveResult {
2648                        archive_zoneid,
2649                        archived_at_ms,
2650                    })
2651                    .unwrap_or_default(),
2652                ))
2653            })
2654        }),
2655    );
2656
2657    // ---- agent:session:list_archives ----
2658    let filestore = state.filestore.clone();
2659    engine.register_handler(
2660        COMMAND_AGENT_SESSION_LIST_ARCHIVES,
2661        Box::new(move |data, _ctx| {
2662            let filestore = filestore.clone();
2663            Box::pin(async move {
2664                let cmd: CommandAgentSessionListArchivesData =
2665                    serde_json::from_value(data).unwrap_or_default();
2666                let summaries = crate::backend::agent_session::list_archives(
2667                    &filestore,
2668                    &cmd.definition_id,
2669                    cmd.limit,
2670                )
2671                .map_err(|e| format!("agent:session:list_archives: {e}"))?;
2672                let rows: Vec<AgentArchiveRow> = summaries
2673                    .into_iter()
2674                    .map(|s| AgentArchiveRow {
2675                        archive_zoneid: s.archive_zoneid,
2676                        archived_at_ms: s.archived_at_ms,
2677                        preview: s.preview,
2678                        node_count: s.node_count,
2679                    })
2680                    .collect();
2681                Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
2682            })
2683        }),
2684    );
2685}
2686
2687/// v7 handlers — Identity bundles (named credential bundles) + Memory bundles.
2688/// See `docs/specs/identity-forge-integration-and-vault-2026-05-08.md`.
2689///
2690/// Identity bundles aggregate accounts (one per provider) under a named
2691/// label, replacing the per-agent `db_agent_identity_links` semantics.
2692/// Memory bundles hold the agent's personality + capability stack.
2693fn register_v7_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
2694    // ---- Identity bundle CRUD ----
2695
2696    let wstore = state.wstore.clone();
2697    engine.register_handler(
2698        COMMAND_LIST_IDENTITY_BUNDLES,
2699        Box::new(move |_data, _ctx| {
2700            let wstore = wstore.clone();
2701            Box::pin(async move {
2702                let bundles = wstore
2703                    .bundle_identity_list()
2704                    .map_err(|e| format!("listidentitybundles: {e}"))?;
2705                Ok(Some(serde_json::to_value(&bundles).unwrap_or_default()))
2706            })
2707        }),
2708    );
2709
2710    let wstore = state.wstore.clone();
2711    engine.register_handler(
2712        COMMAND_GET_IDENTITY_BUNDLE,
2713        Box::new(move |data, _ctx| {
2714            let wstore = wstore.clone();
2715            Box::pin(async move {
2716                let cmd: CommandGetIdentityBundleData = serde_json::from_value(data)
2717                    .map_err(|e| format!("getidentitybundle: {e}"))?;
2718                match wstore
2719                    .bundle_identity_get(&cmd.id)
2720                    .map_err(|e| format!("getidentitybundle: {e}"))?
2721                {
2722                    Some(b) => Ok(Some(serde_json::to_value(&b).unwrap_or_default())),
2723                    None => Err(format!("getidentitybundle: not found id={}", cmd.id)),
2724                }
2725            })
2726        }),
2727    );
2728
2729    let wstore = state.wstore.clone();
2730    let broker = state.broker.clone();
2731    engine.register_handler(
2732        COMMAND_UPSERT_IDENTITY_BUNDLE,
2733        Box::new(move |data, _ctx| {
2734            let wstore = wstore.clone();
2735            let broker = broker.clone();
2736            Box::pin(async move {
2737                let mut bundle: Identity = serde_json::from_value(data)
2738                    .map_err(|e| format!("upsertidentitybundle: {e}"))?;
2739                // Guard on BOTH client-supplied is_blank AND id == "blank".
2740                // Without the id check a caller could send
2741                // {id:"blank", is_blank:false, name:"evil"} and the
2742                // ON CONFLICT(id) DO UPDATE path would rename/re-describe
2743                // the seeded singleton. (reagent P1, 2026-05-08).
2744                if bundle.is_blank || bundle.id == "blank" {
2745                    return Err(
2746                        "upsertidentitybundle: cannot mutate the blank singleton".to_string(),
2747                    );
2748                }
2749                if bundle.id.is_empty() {
2750                    bundle.id = uuid::Uuid::new_v4().to_string();
2751                }
2752                let now = SystemTime::now()
2753                    .duration_since(UNIX_EPOCH)
2754                    .map(|d| d.as_millis() as i64)
2755                    .unwrap_or(0);
2756                if bundle.created_at == 0 {
2757                    bundle.created_at = now;
2758                }
2759                bundle.updated_at = now;
2760                wstore
2761                    .bundle_identity_upsert(&bundle)
2762                    .map_err(|e| format!("upsertidentitybundle: {e}"))?;
2763                broker.publish(crate::backend::wps::WaveEvent {
2764                    event: "identitybundles:changed".to_string(),
2765                    scopes: vec![],
2766                    sender: String::new(),
2767                    persist: 0,
2768                    data: None,
2769                });
2770                Ok(Some(serde_json::to_value(&bundle).unwrap_or_default()))
2771            })
2772        }),
2773    );
2774
2775    let wstore = state.wstore.clone();
2776    let broker = state.broker.clone();
2777    engine.register_handler(
2778        COMMAND_DELETE_IDENTITY_BUNDLE,
2779        Box::new(move |data, _ctx| {
2780            let wstore = wstore.clone();
2781            let broker = broker.clone();
2782            Box::pin(async move {
2783                let cmd: CommandDeleteIdentityBundleData = serde_json::from_value(data)
2784                    .map_err(|e| format!("deleteidentitybundle: {e}"))?;
2785                let deleted = wstore
2786                    .bundle_identity_delete(&cmd.id)
2787                    .map_err(|e| format!("deleteidentitybundle: {e}"))?;
2788                if deleted {
2789                    broker.publish(crate::backend::wps::WaveEvent {
2790                        event: "identitybundles:changed".to_string(),
2791                        scopes: vec![],
2792                        sender: String::new(),
2793                        persist: 0,
2794                        data: None,
2795                    });
2796                }
2797                Ok(Some(json!({ "deleted": deleted })))
2798            })
2799        }),
2800    );
2801
2802    // ---- Identity bundle bindings (junction with accounts) ----
2803
2804    let wstore = state.wstore.clone();
2805    let broker = state.broker.clone();
2806    engine.register_handler(
2807        COMMAND_BIND_IDENTITY_ACCOUNT,
2808        Box::new(move |data, _ctx| {
2809            let wstore = wstore.clone();
2810            let broker = broker.clone();
2811            Box::pin(async move {
2812                let cmd: CommandBindIdentityAccountData = serde_json::from_value(data)
2813                    .map_err(|e| format!("bindidentityaccount: {e}"))?;
2814                wstore
2815                    .bundle_identity_bind(&cmd.identity_id, &cmd.provider, &cmd.account_id)
2816                    .map_err(|e| format!("bindidentityaccount: {e}"))?;
2817                broker.publish(crate::backend::wps::WaveEvent {
2818                    event: format!("identitybundlebindings:changed:{}", cmd.identity_id),
2819                    scopes: vec![],
2820                    sender: String::new(),
2821                    persist: 0,
2822                    data: None,
2823                });
2824                Ok(None)
2825            })
2826        }),
2827    );
2828
2829    let wstore = state.wstore.clone();
2830    let broker = state.broker.clone();
2831    engine.register_handler(
2832        COMMAND_UNBIND_IDENTITY_ACCOUNT,
2833        Box::new(move |data, _ctx| {
2834            let wstore = wstore.clone();
2835            let broker = broker.clone();
2836            Box::pin(async move {
2837                let cmd: CommandUnbindIdentityAccountData = serde_json::from_value(data)
2838                    .map_err(|e| format!("unbindidentityaccount: {e}"))?;
2839                let removed = wstore
2840                    .bundle_identity_unbind(&cmd.identity_id, &cmd.provider)
2841                    .map_err(|e| format!("unbindidentityaccount: {e}"))?;
2842                if removed {
2843                    broker.publish(crate::backend::wps::WaveEvent {
2844                        event: format!("identitybundlebindings:changed:{}", cmd.identity_id),
2845                        scopes: vec![],
2846                        sender: String::new(),
2847                        persist: 0,
2848                        data: None,
2849                    });
2850                }
2851                Ok(Some(json!({ "unbound": removed })))
2852            })
2853        }),
2854    );
2855
2856    let wstore = state.wstore.clone();
2857    engine.register_handler(
2858        COMMAND_LIST_IDENTITY_BINDINGS,
2859        Box::new(move |data, _ctx| {
2860            let wstore = wstore.clone();
2861            Box::pin(async move {
2862                let cmd: CommandListIdentityBindingsData = serde_json::from_value(data)
2863                    .map_err(|e| format!("listidentitybindings: {e}"))?;
2864                let bindings = wstore
2865                    .bundle_identity_bindings(&cmd.identity_id)
2866                    .map_err(|e| format!("listidentitybindings: {e}"))?;
2867                Ok(Some(serde_json::to_value(&bindings).unwrap_or_default()))
2868            })
2869        }),
2870    );
2871
2872    // ---- Memory bundle CRUD ----
2873
2874    let wstore = state.wstore.clone();
2875    engine.register_handler(
2876        COMMAND_LIST_MEMORIES,
2877        Box::new(move |_data, _ctx| {
2878            let wstore = wstore.clone();
2879            Box::pin(async move {
2880                let memories = wstore
2881                    .bundle_memory_list()
2882                    .map_err(|e| format!("listmemories: {e}"))?;
2883                Ok(Some(serde_json::to_value(&memories).unwrap_or_default()))
2884            })
2885        }),
2886    );
2887
2888    let wstore = state.wstore.clone();
2889    engine.register_handler(
2890        COMMAND_GET_MEMORY,
2891        Box::new(move |data, _ctx| {
2892            let wstore = wstore.clone();
2893            Box::pin(async move {
2894                let cmd: CommandGetMemoryData = serde_json::from_value(data)
2895                    .map_err(|e| format!("getmemory: {e}"))?;
2896                match wstore
2897                    .bundle_memory_get(&cmd.id)
2898                    .map_err(|e| format!("getmemory: {e}"))?
2899                {
2900                    Some(m) => Ok(Some(serde_json::to_value(&m).unwrap_or_default())),
2901                    None => Err(format!("getmemory: not found id={}", cmd.id)),
2902                }
2903            })
2904        }),
2905    );
2906
2907    let wstore = state.wstore.clone();
2908    let broker = state.broker.clone();
2909    engine.register_handler(
2910        COMMAND_UPSERT_MEMORY,
2911        Box::new(move |data, _ctx| {
2912            let wstore = wstore.clone();
2913            let broker = broker.clone();
2914            Box::pin(async move {
2915                let mut memory: Memory = serde_json::from_value(data)
2916                    .map_err(|e| format!("upsertmemory: {e}"))?;
2917                // Guard on BOTH client-supplied is_blank AND id == "blank".
2918                // Same bypass as upsertidentitybundle — see that comment.
2919                // (reagent P1, 2026-05-08).
2920                if memory.is_blank || memory.id == "blank" {
2921                    return Err("upsertmemory: cannot mutate the blank singleton".to_string());
2922                }
2923                if memory.id.is_empty() {
2924                    memory.id = uuid::Uuid::new_v4().to_string();
2925                }
2926                let now = SystemTime::now()
2927                    .duration_since(UNIX_EPOCH)
2928                    .map(|d| d.as_millis() as i64)
2929                    .unwrap_or(0);
2930                if memory.created_at == 0 {
2931                    memory.created_at = now;
2932                }
2933                memory.updated_at = now;
2934                wstore
2935                    .bundle_memory_upsert(&memory)
2936                    .map_err(|e| format!("upsertmemory: {e}"))?;
2937                broker.publish(crate::backend::wps::WaveEvent {
2938                    event: "memories:changed".to_string(),
2939                    scopes: vec![],
2940                    sender: String::new(),
2941                    persist: 0,
2942                    data: None,
2943                });
2944                Ok(Some(serde_json::to_value(&memory).unwrap_or_default()))
2945            })
2946        }),
2947    );
2948
2949    let wstore = state.wstore.clone();
2950    let broker = state.broker.clone();
2951    engine.register_handler(
2952        COMMAND_DELETE_MEMORY,
2953        Box::new(move |data, _ctx| {
2954            let wstore = wstore.clone();
2955            let broker = broker.clone();
2956            Box::pin(async move {
2957                let cmd: CommandDeleteMemoryData = serde_json::from_value(data)
2958                    .map_err(|e| format!("deletememory: {e}"))?;
2959                let deleted = wstore
2960                    .bundle_memory_delete(&cmd.id)
2961                    .map_err(|e| format!("deletememory: {e}"))?;
2962                if deleted {
2963                    broker.publish(crate::backend::wps::WaveEvent {
2964                        event: "memories:changed".to_string(),
2965                        scopes: vec![],
2966                        sender: String::new(),
2967                        persist: 0,
2968                        data: None,
2969                    });
2970                }
2971                Ok(Some(json!({ "deleted": deleted })))
2972            })
2973        }),
2974    );
2975
2976    let wstore = state.wstore.clone();
2977    let broker = state.broker.clone();
2978    engine.register_handler(
2979        COMMAND_REORDER_GLOBAL_BRAIN,
2980        Box::new(move |data, _ctx| {
2981            let wstore = wstore.clone();
2982            let broker = broker.clone();
2983            Box::pin(async move {
2984                let cmd: CommandReorderGlobalBrainData = serde_json::from_value(data)
2985                    .map_err(|e| format!("reorderglobalbrain: {e}"))?;
2986                let updated = wstore
2987                    .bundle_memory_reorder(&cmd.ids)
2988                    .map_err(|e| format!("reorderglobalbrain: {e}"))?;
2989                broker.publish(crate::backend::wps::WaveEvent {
2990                    event: "memories:changed".to_string(),
2991                    scopes: vec![],
2992                    sender: String::new(),
2993                    persist: 0,
2994                    data: None,
2995                });
2996                Ok(Some(json!({ "updated": updated })))
2997            })
2998        }),
2999    );
3000}
3001
3002pub fn register_agent_input_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
3003    // subprocessspawn → spawn agent CLI as subprocess for a single turn
3004    let wstore_spawn = state.wstore.clone();
3005    let broker_spawn = state.broker.clone();
3006    let event_bus_spawn = state.event_bus.clone();
3007    let filestore_spawn = state.filestore.clone();
3008    engine.register_handler(
3009        COMMAND_SUBPROCESS_SPAWN,
3010        Box::new(move |data, _ctx| {
3011            let wstore = wstore_spawn.clone();
3012            let broker = broker_spawn.clone();
3013            let event_bus = event_bus_spawn.clone();
3014            let filestore = filestore_spawn.clone();
3015            Box::pin(async move {
3016                let cmd: CommandSubprocessSpawnData = serde_json::from_value(data)
3017                    .map_err(|e| format!("subprocessspawn: {e}"))?;
3018                tracing::info!(
3019                    block_id = %cmd.blockid,
3020                    cli = %cmd.cli_command,
3021                    "SubprocessSpawn"
3022                );
3023
3024                // Get or create a SubprocessController for this block
3025                let ctrl = match blockcontroller::get_controller(&cmd.blockid) {
3026                    Some(c) if c.controller_type() == blockcontroller::BLOCK_CONTROLLER_SUBPROCESS => c,
3027                    _ => {
3028                        // Create and register a new SubprocessController
3029                        let ctrl = blockcontroller::subprocess::SubprocessController::new(
3030                            cmd.tabid.clone(),
3031                            cmd.blockid.clone(),
3032                            Some(broker),
3033                            Some(event_bus),
3034                            Some(wstore),
3035                            Some(filestore),
3036                        );
3037                        let ctrl = std::sync::Arc::new(ctrl);
3038                        ctrl.set_self_ref();
3039                        blockcontroller::register_controller(&cmd.blockid, ctrl.clone());
3040                        ctrl as std::sync::Arc<dyn blockcontroller::Controller>
3041                    }
3042                };
3043
3044                // Downcast to SubprocessController to call spawn_turn
3045                let subprocess_ctrl = ctrl
3046                    .as_any()
3047                    .downcast_ref::<blockcontroller::subprocess::SubprocessController>()
3048                    .ok_or_else(|| "controller is not a SubprocessController".to_string())?;
3049
3050                let config = blockcontroller::subprocess::SubprocessSpawnConfig {
3051                    cli_command: cmd.cli_command,
3052                    cli_args: cmd.cli_args,
3053                    working_dir: cmd.working_dir,
3054                    env_vars: cmd.env_vars,
3055                    message: cmd.message,
3056                    resume_flag: "--resume".to_string(),
3057                    session_id_field: "session_id".to_string(),
3058                    message_id: None,
3059                    // Direct-spawn legacy command — caller doesn't
3060                    // carry a reattach context. Greenfield session id
3061                    // is None; spawn_turn captures it from CLI stdout
3062                    // on the first turn as before.
3063                    session_id: None,
3064                };
3065                subprocess_ctrl.spawn_turn(config)?;
3066                Ok(None)
3067            })
3068        }),
3069    );
3070
3071    // agentinput → send message to agent (persistent or per-turn subprocess)
3072    let wstore_ai = state.wstore.clone();
3073    // Streaming-bash wrapper auth — clone the per-launch auth_key into the
3074    // handler's closure so each spawn can inject it into Claude's env.
3075    // See SPEC_STREAMING_BASH_RUNNER_2026_05_11.md §7.
3076    let auth_key_ai = state.auth_key.clone();
3077    // Broker — passed into the identity-injection path so the OAuth
3078    // expiry probe (PR D, spec §4.4) can publish
3079    // `identitybundlebindings:changed:<bundle_id>` on status change.
3080    let broker_ai = state.broker.clone();
3081    // Container manager — None on hosts without Docker; container agents
3082    // return an error to the caller rather than crashing the server.
3083    let container_manager_ai = state.container_manager.clone();
3084    engine.register_handler(
3085        COMMAND_AGENT_INPUT,
3086        Box::new(move |data, _ctx| {
3087            let wstore = wstore_ai.clone();
3088            let auth_key = auth_key_ai.clone();
3089            let broker = broker_ai.clone();
3090            let container_manager = container_manager_ai.clone();
3091            Box::pin(async move {
3092                let cmd: CommandAgentInputData = serde_json::from_value(data)
3093                    .map_err(|e| format!("agentinput: {e}"))?;
3094                tracing::info!(block_id = %cmd.blockid, "AgentInput");
3095
3096                let ctrl = blockcontroller::get_controller(&cmd.blockid)
3097                    .ok_or_else(|| format!("no controller for block {}", cmd.blockid))?;
3098
3099                // Re-read the spawn config from block metadata
3100                let block: Block = wstore
3101                    .get(&cmd.blockid)
3102                    .map_err(|e| format!("agentinput: load block: {e}"))?
3103                    .ok_or_else(|| format!("block {} not found", cmd.blockid))?;
3104
3105                let cli_command = crate::backend::obj::meta_get_string(
3106                    &block.meta, "cmd", "claude",
3107                );
3108                let cli_args: Vec<String> = match block.meta.get("cmd:args") {
3109                    Some(serde_json::Value::Array(arr)) => arr
3110                        .iter()
3111                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
3112                        .collect(),
3113                    _ => vec![
3114                        "-p".to_string(),
3115                        "--input-format".to_string(),
3116                        "stream-json".to_string(),
3117                        "--output-format".to_string(),
3118                        "stream-json".to_string(),
3119                    ],
3120                };
3121                let working_dir = crate::backend::obj::meta_get_string(
3122                    &block.meta, "cmd:cwd", "",
3123                );
3124                let mut env_vars: std::collections::HashMap<String, String> = match block.meta.get("cmd:env") {
3125                    Some(serde_json::Value::Object(obj)) => obj
3126                        .iter()
3127                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
3128                        .collect(),
3129                    _ => std::collections::HashMap::new(),
3130                };
3131                // Identity injection: look up the active AgentInstance for
3132                // this block, resolve its identity_id's bindings, and merge
3133                // each per-provider env var into the spawn map. Failures
3134                // are logged and skipped — the agent CLI launches with
3135                // whatever resolved cleanly plus the static cmd:env block.
3136                // See agentmux-srv/src/identity/resolver.rs. Broker
3137                // hand-in lets the OAuth expiry probe (PR D, spec §4.4)
3138                // publish `identitybundlebindings:changed:<bundle_id>`
3139                // when it flips a token's status valid→expired etc.
3140                env_vars = crate::identity::resolver::inject_identity_env_async(
3141                    wstore.clone(),
3142                    Some(broker.clone()),
3143                    cmd.blockid.clone(),
3144                    env_vars,
3145                )
3146                .await;
3147                // MuxBus cloud token — injects MUXBUS_TOKEN + MUXBUS_COGNITO_DOMAIN
3148                // if the user has authenticated via muxbus.login. No-op if no
3149                // credentials are stored. Auto-refreshes if token is nearly expired.
3150                crate::server::muxbus_handlers::inject_muxbus_env(&wstore, &mut env_vars);
3151                // Streaming-bash wrapper auth + discovery
3152                // (SPEC_STREAMING_BASH_RUNNER_2026_05_11.md §7).
3153                //
3154                // 1. AGENTMUX_AUTH_KEY — config.rs:42 removed it from
3155                //    the process env at startup (security PR #801).
3156                //    Re-inject for this spawn so the wrapper (running
3157                //    inside Claude's bash subprocess tree) can
3158                //    authenticate against the auth_middleware-gated
3159                //    /agentmux/wps/publish endpoint via X-AuthKey.
3160                // 2. PATH — prepend the bundled tools/bin dir so
3161                //    `agentmux-bashwrap.exe` resolves when the
3162                //    PreToolUse hook (auto-injected by agent_config.rs)
3163                //    rewrites the command to invoke it. AGENTMUX_LOCAL_URL
3164                //    is already in the inherited process env (main.rs:498).
3165                env_vars.insert("AGENTMUX_AUTH_KEY".to_string(), auth_key.clone());
3166                // Block id so the wrapper can scope its WPS publishes
3167                // to `block:<id>`. Without this, chunks publish without
3168                // a scope and the frontend's per-block subscription
3169                // doesn't receive them.
3170                env_vars.insert("AGENTMUX_BLOCKID".to_string(), cmd.blockid.clone());
3171                // Agent display name for MuxBus self-identification.
3172                // muxbus-client reads AGENTMUX_AGENT_ID (preferred) or AGENT_NAME.
3173                // Only set if not already present in cmd:env — user-provided values take precedence.
3174                if !env_vars.contains_key("AGENTMUX_AGENT_ID") {
3175                    let agent_display_name = crate::backend::obj::meta_get_string(
3176                        &block.meta, "agentName", "",
3177                    );
3178                    if !agent_display_name.is_empty() {
3179                        env_vars.insert("AGENTMUX_AGENT_ID".to_string(), agent_display_name);
3180                    }
3181                }
3182                // PATH includes BOTH bundled tools dir (portable
3183                // builds, runtime/tools/bin/) AND user tools dir
3184                // (~/.agentmux/tools/bin/). bundled is None in dev
3185                // mode (target/debug exclusion in tool_store), so
3186                // without user_tools_dir the wrapper wouldn't be on
3187                // the agent's PATH during `task dev`.
3188                {
3189                    let existing = env_vars
3190                        .get("PATH")
3191                        .cloned()
3192                        .or_else(|| std::env::var("PATH").ok())
3193                        .unwrap_or_default();
3194                    let sep = if cfg!(windows) { ";" } else { ":" };
3195                    let mut extras: Vec<String> = Vec::new();
3196                    if let Some(d) = crate::backend::tool_store::bundled_tools_dir() {
3197                        if d.exists() {
3198                            extras.push(d.to_string_lossy().into_owned());
3199                        }
3200                    }
3201                    if let Some(d) = crate::backend::tool_store::user_tools_dir() {
3202                        if d.exists() {
3203                            extras.push(d.to_string_lossy().into_owned());
3204                        }
3205                    }
3206                    if !extras.is_empty() {
3207                        let new_path = format!("{}{}{}", extras.join(sep), sep, existing);
3208                        env_vars.insert("PATH".to_string(), new_path);
3209                    }
3210                }
3211
3212                let session_id_field = crate::backend::obj::meta_get_string(
3213                    &block.meta, "agent:session_id_field", "session_id",
3214                );
3215
3216                // Try persistent controller first, fall back to subprocess
3217                if let Some(persistent_ctrl) = ctrl
3218                    .as_any()
3219                    .downcast_ref::<blockcontroller::persistent::PersistentSubprocessController>()
3220                {
3221                    // Container agents use per-turn docker exec — incompatible with a
3222                    // long-lived persistent subprocess. Fail loudly instead of silently
3223                    // spawning the CLI on the host.
3224                    let agent_mode = crate::backend::obj::meta_get_string(&block.meta, "agentMode", "host");
3225                    if agent_mode == "container" {
3226                        return Err("container agents require a subprocess controller; this provider uses a persistent controller".to_string());
3227                    }
3228                    // Resume support: a /model (or effort/permission) change
3229                    // respawns the persistent CLI with new flags; pass the
3230                    // resume flag + captured session id so the respawn continues
3231                    // the same conversation. Same meta keys the subprocess path
3232                    // reads below. Without this, switching model on a persistent
3233                    // agent would either no-op (old behavior) or lose context.
3234                    let resume_flag = crate::backend::obj::meta_get_string(
3235                        &block.meta, "agent:resume_flag", "--resume",
3236                    );
3237                    let persisted_session_id = crate::backend::obj::meta_get_string(
3238                        &block.meta, "agent:sessionid", "",
3239                    );
3240                    let config = blockcontroller::persistent::PersistentSpawnConfig {
3241                        cli_command,
3242                        cli_args,
3243                        working_dir,
3244                        env_vars,
3245                        session_id_field,
3246                        resume_flag,
3247                        session_id: persisted_session_id,
3248                        message_id: cmd.message_id.clone(),
3249                    };
3250                    persistent_ctrl.send_message(cmd.message, config)?;
3251                } else if let Some(subprocess_ctrl) = ctrl
3252                    .as_any()
3253                    .downcast_ref::<blockcontroller::subprocess::SubprocessController>()
3254                {
3255                    let resume_flag = crate::backend::obj::meta_get_string(
3256                        &block.meta, "agent:resume_flag", "--resume",
3257                    );
3258                    // Picker reattach: the frontend writes the prior
3259                    // block's session id here when launching with
3260                    // `continueOfInstanceId`. spawn_turn hydrates its
3261                    // inner.session_id from this on the first turn so
3262                    // --resume <sid> lands on the very first launch.
3263                    let persisted_session_id = crate::backend::obj::meta_get_string(
3264                        &block.meta, "agent:sessionid", "",
3265                    );
3266
3267                    // Container agent branch: use Docker socket API exec (P1a: no
3268                    // secrets in argv). Host agent branch: regular CLI subprocess.
3269                    let agent_mode = crate::backend::obj::meta_get_string(
3270                        &block.meta, "agentMode", "host",
3271                    );
3272                    if agent_mode == "container" {
3273                        let cm = container_manager.as_deref()
3274                            .ok_or_else(|| "Docker not available on this host; cannot start container agent".to_string())?;
3275                        let container_image = crate::backend::obj::meta_get_string(
3276                            &block.meta, "agent:container_image", "ghcr.io/agentmuxai/agent-claude:latest",
3277                        );
3278                        // Use agentId (UUID) — always valid as a Docker name; display names can have spaces.
3279                        let agent_id = crate::backend::obj::meta_get_string(
3280                            &block.meta, "agentId", "",
3281                        );
3282                        let container_name = crate::backend::container::container_name_for_slug(&agent_id);
3283                        let volumes_json = crate::backend::obj::meta_get_string(
3284                            &block.meta, "agent:container_volumes", "[]",
3285                        );
3286                        let volumes: Vec<String> = serde_json::from_str(&volumes_json).unwrap_or_default();
3287
3288                        // Ensure container is alive (pull image if needed — P1b).
3289                        cm.ensure_running(&container_name, &container_image, &volumes, &[]).await
3290                            .map_err(|e| format!("container ensure_running failed: {e}"))?;
3291
3292                        tracing::info!(
3293                            block_id = %cmd.blockid,
3294                            container = %container_name,
3295                            image = %container_image,
3296                            "container agent turn: bollard exec (env via Docker socket, not argv)",
3297                        );
3298
3299                        // Env is passed via CreateExecOptions.env (Docker socket API),
3300                        // NOT as -e KEY=VALUE argv args — this prevents CWE-214 exposure.
3301                        // spawn_container_turn filters config.env_vars (denylist) per
3302                        // turn, so cmd:cwd (host path) and host-path vars never reach
3303                        // the container, and each queued turn uses its own env.
3304
3305                        // Base cmd: [container_command, ...cli_args]. The command
3306                        // is the provider CLI resolved INSIDE the image (on PATH,
3307                        // e.g. `claude`) — NOT `cli_command`/`cmd`, which is the
3308                        // host-resolved absolute npm path and does not exist in the
3309                        // container (docker exec would fail "no such file or
3310                        // directory"). cli_args are format flags (-p, --input-format
3311                        // …) + provider flags — no host paths, safe as-is.
3312                        // spawn_container_turn appends --resume <sid> internally.
3313                        let container_command = crate::backend::obj::meta_get_string(
3314                            &block.meta, "agent:container_command", "claude",
3315                        );
3316                        let mut base_cmd = vec![container_command];
3317                        base_cmd.extend(cli_args);
3318
3319                        let config = blockcontroller::subprocess::SubprocessSpawnConfig {
3320                            cli_command: String::new(), // unused by spawn_container_turn
3321                            cli_args: vec![],           // unused by spawn_container_turn
3322                            working_dir: String::new(), // unused — container has own cwd
3323                            env_vars,
3324                            message: cmd.message,
3325                            resume_flag,
3326                            session_id_field,
3327                            message_id: cmd.message_id,
3328                            session_id: if persisted_session_id.is_empty() {
3329                                None
3330                            } else {
3331                                Some(persisted_session_id)
3332                            },
3333                        };
3334                        subprocess_ctrl.spawn_container_turn(cm.clone(), container_name, base_cmd, config)?;
3335                    } else {
3336                        // Host agent: regular CLI subprocess (env set on child process, not in argv).
3337                        let config = blockcontroller::subprocess::SubprocessSpawnConfig {
3338                            cli_command,
3339                            cli_args,
3340                            working_dir,
3341                            env_vars,
3342                            message: cmd.message,
3343                            resume_flag,
3344                            session_id_field,
3345                            message_id: cmd.message_id,
3346                            session_id: if persisted_session_id.is_empty() {
3347                                None
3348                            } else {
3349                                Some(persisted_session_id)
3350                            },
3351                        };
3352                        subprocess_ctrl.spawn_turn(config)?;
3353                    }
3354                } else {
3355                    return Err("controller is not a SubprocessController or PersistentSubprocessController".to_string());
3356                }
3357
3358                // Register with cloud subscriber + reactive handler so cloud-injected
3359                // messages (e.g. GitHub PR review notifications) reach this agent.
3360                // Uses agentName (the logical display name, e.g. "smike") as the key —
3361                // matching the namespace used by reactive.rs:233 (`req.agent_id`) and the
3362                // delivery path (`agent_to_block` keyed by lowercased logical agent_id).
3363                // PR bodies embed $AGENTMUX_AGENT_ID (same value) so the cloud injection
3364                // key and the poll key are always consistent.
3365                // Both calls are idempotent: add_agent skips the WS send if already
3366                // subscribed; register_agent replaces any stale mapping from a prior session.
3367                let agent_name = crate::backend::obj::meta_get_string(
3368                    &block.meta, "agentName", "",
3369                );
3370                if !agent_name.is_empty() {
3371                    let registered = crate::backend::reactive::handler::get_global_handler()
3372                        .register_agent(&agent_name, &cmd.blockid, None);
3373                    if registered.is_ok() {
3374                        if let Some(sub) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
3375                            sub.add_agent(&agent_name);
3376                        }
3377                    }
3378                }
3379
3380                Ok(None)
3381            })
3382        }),
3383    );
3384
3385    // agentstop → stop the running agent subprocess
3386    engine.register_handler(
3387        COMMAND_AGENT_STOP,
3388        Box::new(|data, _ctx| {
3389            Box::pin(async move {
3390                let cmd: CommandAgentStopData = serde_json::from_value(data)
3391                    .map_err(|e| format!("agentstop: {e}"))?;
3392                tracing::info!(block_id = %cmd.blockid, force = cmd.force, "AgentStop");
3393                match blockcontroller::get_controller(&cmd.blockid) {
3394                    Some(ctrl) => {
3395                        ctrl.stop(!cmd.force, blockcontroller::STATUS_DONE)?;
3396                        // Deregister: unregister_block cleans up both agent_to_block and
3397                        // block_to_agent maps; remove_agent then removes the cloud poll entry
3398                        // using the logical agent_id recovered from block_to_agent.
3399                        let handler = crate::backend::reactive::handler::get_global_handler();
3400                        let agent_name = handler.agent_id_for_block(&cmd.blockid);
3401                        handler.unregister_block(&cmd.blockid);
3402                        if let (Some(sub), Some(name)) = (
3403                            crate::muxbus::cloud_subscriber::get_global_subscriber(),
3404                            agent_name,
3405                        ) {
3406                            sub.remove_agent(&name);
3407                        }
3408                        Ok(None)
3409                    }
3410                    None => Ok(None),
3411                }
3412            })
3413        }),
3414    );
3415}
3416
3417/// Read the per-block `output.state.json` snapshot from filestore and
3418/// extract a `(preview, node_count)` pair for the AgentPicker's
3419/// "Recent sessions" list.
3420///
3421/// The snapshot shape is owned by the frontend (see
3422/// `frontend/app/view/agent/agent-view.tsx::writeSnapshotNow`):
3423/// `{ schemaVersion, savedAt, highWaterMark, historyOffset, nodes: [DocumentNode...] }`.
3424/// We only touch two fields:
3425/// - `nodes.length` → `node_count`.
3426/// - The first node with `type === "user_message"`, `message` field →
3427///   `preview` (trimmed, newlines collapsed, max 240 chars).
3428///
3429/// On any error (snapshot missing, malformed JSON, no user message),
3430/// returns `("", 0)`. Callers treat that the same as "no preview".
3431fn read_session_preview(
3432    filestore: &crate::backend::storage::filestore::FileStore,
3433    block_id: &str,
3434) -> (String, usize) {
3435    let bytes = match filestore.read_file(block_id, "output.state.json") {
3436        Ok(Some(b)) => b,
3437        _ => return (String::new(), 0),
3438    };
3439    // Cap the parse budget — a misbehaving / corrupted snapshot
3440    // shouldn't be able to stall this handler. 4MiB is well above the
3441    // typical conversation snapshot (Maks's was ~750KiB for 169 nodes)
3442    // but bounded enough to fail fast on garbage.
3443    if bytes.len() > 4 * 1024 * 1024 {
3444        tracing::warn!(
3445            block_id = %block_id,
3446            size = bytes.len(),
3447            "listrecentsessions: snapshot too large; skipping preview"
3448        );
3449        return (String::new(), 0);
3450    }
3451    let json: serde_json::Value = match serde_json::from_slice(&bytes) {
3452        Ok(v) => v,
3453        Err(_) => return (String::new(), 0),
3454    };
3455    let nodes = match json.get("nodes").and_then(|v| v.as_array()) {
3456        Some(a) => a,
3457        None => return (String::new(), 0),
3458    };
3459    let node_count = nodes.len();
3460    // First user_message wins. Skip the bootstrap "Session Context"
3461    // prompt when present — it's always the first node and is system
3462    // boilerplate the user didn't type; if a subsequent user_message
3463    // exists, that's the more useful preview. Heuristic: if the first
3464    // user message starts with "# Session Context", scan for the next.
3465    let mut preview = String::new();
3466    for node in nodes {
3467        let ty = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
3468        if ty != "user_message" {
3469            continue;
3470        }
3471        let msg = node
3472            .get("message")
3473            .and_then(|v| v.as_str())
3474            .unwrap_or("")
3475            .trim();
3476        if msg.is_empty() {
3477            continue;
3478        }
3479        if preview.is_empty() && msg.starts_with("# Session Context") {
3480            // Stash as fallback in case there's no later user_message.
3481            preview = collapse_preview(msg);
3482            continue;
3483        }
3484        preview = collapse_preview(msg);
3485        break;
3486    }
3487    (preview, node_count)
3488}
3489
3490/// Collapse newlines + extra whitespace, cap at 240 chars. Output is
3491/// safe to render inline in a single-line preview row.
3492fn collapse_preview(s: &str) -> String {
3493    const MAX_CHARS: usize = 240;
3494    let mut buf = String::with_capacity(s.len().min(MAX_CHARS + 4));
3495    let mut prev_space = false;
3496    for ch in s.chars() {
3497        if buf.chars().count() >= MAX_CHARS {
3498            buf.push('\u{2026}'); // "…"
3499            return buf;
3500        }
3501        if ch.is_whitespace() {
3502            if !prev_space && !buf.is_empty() {
3503                buf.push(' ');
3504                prev_space = true;
3505            }
3506        } else {
3507            buf.push(ch);
3508            prev_space = false;
3509        }
3510    }
3511    buf
3512}
3513
3514#[cfg(test)]
3515mod recent_sessions_tests {
3516    use super::*;
3517    use crate::backend::storage::filestore::FileStore;
3518
3519    fn fresh_filestore() -> std::sync::Arc<FileStore> {
3520        std::sync::Arc::new(FileStore::open_in_memory().unwrap())
3521    }
3522
3523    fn write_snapshot(fs: &FileStore, block_id: &str, body: &str) {
3524        // make_file then write_file mirrors the production
3525        // BlockfileWriteState handler path.
3526        let meta: crate::backend::storage::filestore::FileMeta =
3527            std::collections::HashMap::new();
3528        let opts = crate::backend::storage::filestore::FileOpts::default();
3529        fs.make_file(block_id, "output.state.json", meta, opts)
3530            .expect("make_file");
3531        fs.write_file(block_id, "output.state.json", body.as_bytes())
3532            .expect("write_file");
3533    }
3534
3535    #[test]
3536    fn collapse_preview_strips_newlines_and_caps_length() {
3537        let s = "hello\n\nworld\n  next   line";
3538        assert_eq!(collapse_preview(s), "hello world next line");
3539        let long: String = "a".repeat(500);
3540        let out = collapse_preview(&long);
3541        // 240 chars + ellipsis.
3542        assert!(out.ends_with('\u{2026}'));
3543        assert!(out.chars().count() <= 241);
3544    }
3545
3546    #[test]
3547    fn read_session_preview_missing_returns_zero() {
3548        let fs = fresh_filestore();
3549        let (preview, count) = read_session_preview(&fs, "no-such-block");
3550        assert_eq!(preview, "");
3551        assert_eq!(count, 0);
3552    }
3553
3554    #[test]
3555    fn read_session_preview_extracts_first_user_message_skipping_context() {
3556        let fs = fresh_filestore();
3557        // Two user messages: first is the boilerplate Session Context;
3558        // second is the user's real prompt. Preview should be the real one.
3559        let snapshot = serde_json::json!({
3560            "schemaVersion": 1,
3561            "savedAt": "2026-05-23T08:00:00Z",
3562            "highWaterMark": 169,
3563            "historyOffset": 0,
3564            "nodes": [
3565                {
3566                    "type": "user_message",
3567                    "id": "u0",
3568                    "timestamp": 0,
3569                    "collapsed": false,
3570                    "summary": "👤 User Message",
3571                    "message": "# Session Context\nIdentity: Claude\n## Description\nStartup boilerplate"
3572                },
3573                { "type": "markdown", "id": "m0", "content": "ack" },
3574                {
3575                    "type": "user_message",
3576                    "id": "u1",
3577                    "timestamp": 100,
3578                    "collapsed": false,
3579                    "summary": "👤 User Message",
3580                    "message": "check the agentmuxai/agentmux history, get the latest code"
3581                }
3582            ]
3583        });
3584        write_snapshot(&fs, "blk-1", &snapshot.to_string());
3585        let (preview, count) = read_session_preview(&fs, "blk-1");
3586        assert_eq!(count, 3);
3587        assert!(preview.starts_with("check the agentmuxai/agentmux"));
3588    }
3589
3590    #[test]
3591    fn read_session_preview_falls_back_to_session_context_when_only_one() {
3592        let fs = fresh_filestore();
3593        let snapshot = serde_json::json!({
3594            "schemaVersion": 1,
3595            "nodes": [
3596                {
3597                    "type": "user_message",
3598                    "id": "u0",
3599                    "message": "# Session Context\nIdentity: Claude\nStartup boilerplate"
3600                }
3601            ]
3602        });
3603        write_snapshot(&fs, "blk-2", &snapshot.to_string());
3604        let (preview, count) = read_session_preview(&fs, "blk-2");
3605        assert_eq!(count, 1);
3606        // Newlines collapsed; starts with the boilerplate marker.
3607        assert!(preview.starts_with("# Session Context"));
3608    }
3609
3610    #[test]
3611    fn read_session_preview_handles_malformed_json() {
3612        let fs = fresh_filestore();
3613        write_snapshot(&fs, "blk-3", "not valid json {");
3614        let (preview, count) = read_session_preview(&fs, "blk-3");
3615        assert_eq!(preview, "");
3616        assert_eq!(count, 0);
3617    }
3618
3619    #[test]
3620    fn read_session_preview_handles_no_user_messages() {
3621        let fs = fresh_filestore();
3622        let snapshot = serde_json::json!({
3623            "schemaVersion": 1,
3624            "nodes": [
3625                { "type": "markdown", "id": "m0", "content": "system note" }
3626            ]
3627        });
3628        write_snapshot(&fs, "blk-4", &snapshot.to_string());
3629        let (preview, count) = read_session_preview(&fs, "blk-4");
3630        assert_eq!(preview, "");
3631        assert_eq!(count, 1);
3632    }
3633
3634    // ── Integration test: full listrecentsessions handler ────────────
3635    //
3636    // Spins up the same engine + state shape as the production
3637    // websocket path so the handler runs end-to-end against an
3638    // in-memory wstore + filestore. Asserts the row shape, the
3639    // identity filter, the snapshot-first sort, the preview extraction,
3640    // and the cross-version "no snapshot" fallback. This is the
3641    // backend correctness gate for the AgentPicker's Recent Sessions
3642    // surface (cascade follow-up 2026-05-23).
3643    use crate::backend::storage::store::{
3644        AgentDefinition, AgentInstance, Identity, InstanceStatus, Memory, Store,
3645    };
3646    use crate::backend::rpc::engine::WshRpcEngine;
3647    use crate::server::AppState;
3648    use std::sync::Arc;
3649
3650    /// Drive a single RPC round-trip against the in-memory engine,
3651    /// asserting success + deserializing the JSON payload into `T`.
3652    async fn call_rpc<T: serde::de::DeserializeOwned>(
3653        engine: &Arc<WshRpcEngine>,
3654        rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
3655        command: &str,
3656        data: serde_json::Value,
3657    ) -> T {
3658        let req_id = format!("test-{}", uuid::Uuid::new_v4());
3659        let msg = crate::backend::rpc_types::RpcMessage {
3660            command: command.to_string(),
3661            reqid: req_id.clone(),
3662            data: Some(data),
3663            ..Default::default()
3664        };
3665        engine.handle_message(msg);
3666        let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
3667            .await
3668            .expect("handler timed out")
3669            .expect("output channel closed");
3670        assert_eq!(resp.resid, req_id, "unexpected response id");
3671        assert!(resp.error.is_empty(), "handler returned error: {}", resp.error);
3672        let payload = resp.data.unwrap_or(serde_json::Value::Null);
3673        serde_json::from_value(payload).expect("response deserialize")
3674    }
3675
3676    fn build_state_with_seed() -> (
3677        AppState,
3678        Arc<WshRpcEngine>,
3679        tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
3680    ) {
3681        let wstore = Arc::new(Store::open_in_memory().unwrap());
3682        let filestore = Arc::new(FileStore::open_in_memory().unwrap());
3683        let event_bus = Arc::new(crate::backend::eventbus::EventBus::new());
3684        let broker = Arc::new(crate::backend::wps::Broker::new());
3685        let reactive_handler = crate::backend::reactive::get_global_handler();
3686        let poller = Arc::new(crate::backend::reactive::Poller::new(
3687            crate::backend::reactive::PollerConfig {
3688                agentmux_url: None,
3689                agentmux_token: None,
3690                poll_interval_secs: 30,
3691            },
3692            reactive_handler,
3693        ));
3694        crate::backend::wcore::ensure_initial_data(&wstore).unwrap();
3695        let config_watcher = Arc::new(crate::backend::wconfig::ConfigWatcher::new());
3696        let process_tracker = Arc::new(
3697            crate::backend::process_tracker::registry::AgentProcessRegistry::new(Some(broker.clone())),
3698        );
3699        let state = AppState {
3700            auth_key: "test".to_string(),
3701            version: "test".to_string(),
3702            app_path: String::new(),
3703            wstore: wstore.clone(),
3704            filestore: filestore.clone(),
3705            global_transcript_store: None,
3706            event_bus: event_bus.clone(),
3707            broker,
3708            reactive_handler,
3709            poller,
3710            config_watcher,
3711            messagebus: Arc::new(crate::backend::messagebus::MessageBus::new()),
3712            http_client: reqwest::Client::new(),
3713            local_web_url: String::new(),
3714            subagent_watcher: Arc::new(crate::backend::subagent_watcher::SubagentWatcher::new(event_bus.clone())),
3715            history_service: Arc::new(crate::backend::history::HistoryService::new()),
3716            lan_discovery: Arc::new(crate::backend::lan_discovery::LanDiscoveryController::new(
3717                "test-instance".to_string(),
3718                "test-host".to_string(),
3719                "0.28.20".to_string(),
3720                0,
3721                event_bus.clone(),
3722                String::new(),
3723            )),
3724            lsp_supervisor: Arc::new(crate::backend::lsp::LspSupervisor::new(event_bus.clone())),
3725            process_tracker,
3726            srv_state: Arc::new(tokio::sync::Mutex::new(crate::state::State::default())),
3727            srv_events_tx: tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(64).0,
3728            saga_id_alloc: Arc::new(std::sync::atomic::AtomicU64::new(0)),
3729            saga_log: Arc::new(crate::sagas::log::SagaLog::open_in_memory().unwrap()),
3730            auth_session_manager: Arc::new(crate::identity::auth_session::AuthSessionManager::new()),
3731            install_sessions: crate::server::install_handlers::InstallSessionRegistry::new(),
3732            container_manager: None,
3733            shell_sessions: crate::backend::shell_node::ShellSessionRegistry::new(),
3734        };
3735
3736        // Seed: 1 SEEDED definition (template), 1 identity bundle, 1
3737        // memory bundle. Phase 3b note: seeded as a template so that
3738        // each instance projection in `db_agents` lands on its own row
3739        // (`is_template = 0`, `id = inst.id`, `parent_template_id =
3740        // def.id`) rather than folding into the def-projection and
3741        // clobbering its name. The handler resolves `definition_name`
3742        // via `defs.iter().find(|d| d.id == inst.definition_id)`, which
3743        // hits the template row and returns "Claude Code". Under the
3744        // pre-Phase 3b reader, def name was always preserved because
3745        // `agent_def_list` queried `db_agent_definitions` directly;
3746        // db_agents fold semantics require the seed shape to avoid
3747        // the collision.
3748        let def = AgentDefinition {
3749            id: "def-claude".to_string(),
3750            slug: "claude-code".to_string(),
3751            name: "Claude Code".to_string(),
3752            icon: String::new(),
3753            provider: "claude".to_string(),
3754            description: String::new(),
3755            working_directory: String::new(),
3756            shell: String::new(),
3757            provider_flags: String::new(),
3758            auto_start: 0,
3759            restart_on_crash: 0,
3760            idle_timeout_minutes: 0,
3761            created_at: 0,
3762            agent_type: "host".to_string(),
3763            environment: String::new(),
3764            agent_bus_id: String::new(),
3765            is_seeded: 1,
3766            accounts: String::new(),
3767            parent_id: String::new(),
3768            branch_label: String::new(),
3769            updated_at: 0,
3770            user_hidden: 0,
3771            container_image: String::new(),
3772            container_volumes: "[]".to_string(),
3773            container_name: String::new(),
3774        };
3775        let mut def_mut = def.clone();
3776        wstore.agent_def_insert(&mut def_mut).unwrap();
3777        let identity = Identity {
3778            id: "id-work".to_string(),
3779            name: "Work".to_string(),
3780            description: String::new(),
3781            is_blank: false,
3782            created_at: 0,
3783            updated_at: 0,
3784        };
3785        wstore.bundle_identity_upsert(&identity).unwrap();
3786        let memory = Memory {
3787            id: "mem-notes".to_string(),
3788            name: "Notes".to_string(),
3789            description: String::new(),
3790            is_blank: false,
3791            is_global: false,
3792            provider: String::new(),
3793            model: String::new(),
3794            instructions: String::new(),
3795            context_files: "[]".to_string(),
3796            mcp_servers: "[]".to_string(),
3797            skills: "[]".to_string(),
3798            sort_order: 0,
3799            created_at: 0,
3800            updated_at: 0,
3801        };
3802        wstore.bundle_memory_upsert(&memory).unwrap();
3803
3804        // 3 instances:
3805        //   - blk-recent: has snapshot, more recent activity
3806        //   - blk-older:  has snapshot, older activity
3807        //   - blk-none:   no snapshot at all (legacy / pre-persistence row)
3808        // All three use the same identity bundle so the filter test
3809        // can also exercise it without re-seeding.
3810        for (id, block, started) in [
3811            ("inst-recent", "blk-recent", 1_700_000_100_000_i64),
3812            ("inst-older", "blk-older", 1_700_000_000_000_i64),
3813            ("inst-none", "blk-none", 1_700_000_050_000_i64),
3814        ] {
3815            let inst = AgentInstance {
3816                id: id.to_string(),
3817                definition_id: "def-claude".to_string(),
3818                parent_instance_id: String::new(),
3819                block_id: block.to_string(),
3820                session_id: String::new(),
3821                status: InstanceStatus::Running.as_str().to_string(),
3822                github_context: String::new(),
3823                started_at: started,
3824                ended_at: 0,
3825                created_at: started,
3826                identity_id: "id-work".to_string(),
3827                memory_id: "mem-notes".to_string(),
3828                instance_name: format!("name-{id}"),
3829                working_directory: format!("/tmp/{id}"),
3830                display_hidden: false,
3831            };
3832            wstore.instance_create(&inst).unwrap();
3833        }
3834
3835        // Snapshots for the two with snapshots. Write the OLDER one
3836        // first so its filestore-stamped modts is strictly less than the
3837        // recent one — the handler sorts snapshot-bearing rows by modts
3838        // desc, so writing blk-older second would invert the assertions.
3839        // (Pre-Phase 3b this ordering was fragile because the dual-write
3840        // chain ran fewer SQL statements between successive inserts, so
3841        // adjacent writes landed in the same millisecond and the stable
3842        // sort preserved instance_list_named's started_at order; now the
3843        // additional db_agents UPDATE per instance widens the gap and
3844        // distinct modts dominate the stable sort.)
3845        let snap_older = serde_json::json!({
3846            "schemaVersion": 1,
3847            "nodes": [
3848                {"type": "user_message", "id": "u0",
3849                 "message": "earlier conversation"}
3850            ]
3851        });
3852        write_snapshot(&filestore, "blk-older", &snap_older.to_string());
3853        let snap_recent = serde_json::json!({
3854            "schemaVersion": 1,
3855            "nodes": [
3856                {"type": "user_message", "id": "u0",
3857                 "message": "# Session Context\nboilerplate"},
3858                {"type": "markdown", "id": "m0", "content": "ack"},
3859                {"type": "user_message", "id": "u1",
3860                 "message": "fix the live-feed hover delay"}
3861            ]
3862        });
3863        write_snapshot(&filestore, "blk-recent", &snap_recent.to_string());
3864
3865        let (engine, rx) = WshRpcEngine::new();
3866        super::register_agent_handlers(&engine, &state);
3867        (state, engine, rx)
3868    }
3869
3870    #[tokio::test]
3871    async fn handler_returns_sessions_with_previews_sorted_by_snapshot_first() {
3872        let (_state, engine, mut rx) = build_state_with_seed();
3873        let rows: Vec<RecentSessionRow> = call_rpc(
3874            &engine,
3875            &mut rx,
3876            COMMAND_LIST_RECENT_SESSIONS,
3877            serde_json::json!({}),
3878        )
3879        .await;
3880        assert_eq!(rows.len(), 3, "all three sessions surfaced");
3881
3882        // Sort: snapshot-bearing rows first (recent then older), then
3883        // the no-snapshot row at the tail.
3884        assert_eq!(rows[0].instance_id, "inst-recent");
3885        assert!(rows[0].has_snapshot);
3886        assert_eq!(rows[0].node_count, 3);
3887        assert!(
3888            rows[0].preview.starts_with("fix the live-feed"),
3889            "preview should be the post-context user message, got {:?}",
3890            rows[0].preview
3891        );
3892
3893        assert_eq!(rows[1].instance_id, "inst-older");
3894        assert!(rows[1].has_snapshot);
3895        assert_eq!(rows[1].node_count, 1);
3896        assert_eq!(rows[1].preview, "earlier conversation");
3897
3898        assert_eq!(rows[2].instance_id, "inst-none");
3899        assert!(!rows[2].has_snapshot);
3900        assert_eq!(rows[2].node_count, 0);
3901        assert_eq!(rows[2].preview, "");
3902
3903        // Joins: definition + identity + memory names resolved.
3904        assert_eq!(rows[0].definition_name, "Claude Code");
3905        assert_eq!(rows[0].identity_name, "Work");
3906        assert_eq!(rows[0].memory_name, "Notes");
3907        assert_eq!(rows[0].block_id_hint, "blk-recent");
3908    }
3909
3910    #[tokio::test]
3911    async fn handler_identity_filter_restricts_rows() {
3912        let (_state, engine, mut rx) = build_state_with_seed();
3913        // Filter to a non-existent identity → empty list.
3914        let rows: Vec<RecentSessionRow> = call_rpc(
3915            &engine,
3916            &mut rx,
3917            COMMAND_LIST_RECENT_SESSIONS,
3918            serde_json::json!({ "identity_id": "no-such-bundle" }),
3919        )
3920        .await;
3921        assert_eq!(rows.len(), 0);
3922
3923        // Filter to the seeded one → all three.
3924        let rows: Vec<RecentSessionRow> = call_rpc(
3925            &engine,
3926            &mut rx,
3927            COMMAND_LIST_RECENT_SESSIONS,
3928            serde_json::json!({ "identity_id": "id-work" }),
3929        )
3930        .await;
3931        assert_eq!(rows.len(), 3);
3932
3933        // Empty-string identity_id is treated as "no filter" so the
3934        // frontend can pass `""` without special-casing.
3935        let rows: Vec<RecentSessionRow> = call_rpc(
3936            &engine,
3937            &mut rx,
3938            COMMAND_LIST_RECENT_SESSIONS,
3939            serde_json::json!({ "identity_id": "" }),
3940        )
3941        .await;
3942        assert_eq!(rows.len(), 3);
3943    }
3944
3945    #[tokio::test]
3946    async fn handler_respects_limit() {
3947        let (_state, engine, mut rx) = build_state_with_seed();
3948        let rows: Vec<RecentSessionRow> = call_rpc(
3949            &engine,
3950            &mut rx,
3951            COMMAND_LIST_RECENT_SESSIONS,
3952            serde_json::json!({ "limit": 1 }),
3953        )
3954        .await;
3955        assert_eq!(rows.len(), 1);
3956        assert_eq!(rows[0].instance_id, "inst-recent");
3957    }
3958
3959    // ---- Two-tier picker Phase 1: create-from-template + listagents filter ----
3960    //
3961    // SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md.
3962
3963    /// Same shape as build_state_with_seed but with a seeded template
3964    /// and no instances, so the create-from-template path is exercised
3965    /// against a known-good template row.
3966    fn build_state_with_template_seed() -> (
3967        AppState,
3968        Arc<WshRpcEngine>,
3969        tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
3970    ) {
3971        let wstore = Arc::new(Store::open_in_memory().unwrap());
3972        let filestore = Arc::new(FileStore::open_in_memory().unwrap());
3973        let event_bus = Arc::new(crate::backend::eventbus::EventBus::new());
3974        let broker = Arc::new(crate::backend::wps::Broker::new());
3975        let reactive_handler = crate::backend::reactive::get_global_handler();
3976        let poller = Arc::new(crate::backend::reactive::Poller::new(
3977            crate::backend::reactive::PollerConfig {
3978                agentmux_url: None,
3979                agentmux_token: None,
3980                poll_interval_secs: 30,
3981            },
3982            reactive_handler,
3983        ));
3984        crate::backend::wcore::ensure_initial_data(&wstore).unwrap();
3985        let config_watcher = Arc::new(crate::backend::wconfig::ConfigWatcher::new());
3986        let process_tracker = Arc::new(
3987            crate::backend::process_tracker::registry::AgentProcessRegistry::new(Some(broker.clone())),
3988        );
3989        let state = AppState {
3990            auth_key: "test".to_string(),
3991            version: "test".to_string(),
3992            app_path: String::new(),
3993            wstore: wstore.clone(),
3994            filestore: filestore.clone(),
3995            global_transcript_store: None,
3996            event_bus: event_bus.clone(),
3997            broker,
3998            reactive_handler,
3999            poller,
4000            config_watcher,
4001            messagebus: Arc::new(crate::backend::messagebus::MessageBus::new()),
4002            http_client: reqwest::Client::new(),
4003            local_web_url: String::new(),
4004            subagent_watcher: Arc::new(crate::backend::subagent_watcher::SubagentWatcher::new(event_bus.clone())),
4005            history_service: Arc::new(crate::backend::history::HistoryService::new()),
4006            lan_discovery: Arc::new(crate::backend::lan_discovery::LanDiscoveryController::new(
4007                "test-instance".to_string(),
4008                "test-host".to_string(),
4009                "0.28.20".to_string(),
4010                0,
4011                event_bus.clone(),
4012                String::new(),
4013            )),
4014            lsp_supervisor: Arc::new(crate::backend::lsp::LspSupervisor::new(event_bus.clone())),
4015            process_tracker,
4016            srv_state: Arc::new(tokio::sync::Mutex::new(crate::state::State::default())),
4017            srv_events_tx: tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(64).0,
4018            saga_id_alloc: Arc::new(std::sync::atomic::AtomicU64::new(0)),
4019            saga_log: Arc::new(crate::sagas::log::SagaLog::open_in_memory().unwrap()),
4020            auth_session_manager: Arc::new(crate::identity::auth_session::AuthSessionManager::new()),
4021            install_sessions: crate::server::install_handlers::InstallSessionRegistry::new(),
4022            container_manager: None,
4023            shell_sessions: crate::backend::shell_node::ShellSessionRegistry::new(),
4024        };
4025
4026        // One seeded template + one already-user-owned definition.
4027        let mut tpl = AgentDefinition {
4028            id: "tpl-claude".to_string(),
4029            slug: String::new(),
4030            name: "Claude Code".to_string(),
4031            icon: String::new(),
4032            provider: "claude".to_string(),
4033            description: "Anthropic's coding agent".to_string(),
4034            working_directory: String::new(),
4035            shell: String::new(),
4036            provider_flags: "--model haiku".to_string(),
4037            auto_start: 0,
4038            restart_on_crash: 0,
4039            idle_timeout_minutes: 0,
4040            created_at: 1_700_000_000_000,
4041            agent_type: "host".to_string(),
4042            environment: String::new(),
4043            agent_bus_id: String::new(),
4044            is_seeded: 1,
4045            accounts: String::new(),
4046            parent_id: String::new(),
4047            branch_label: String::new(),
4048            updated_at: 1_700_000_000_000,
4049            user_hidden: 0,
4050            container_image: String::new(),
4051            container_volumes: "[]".to_string(),
4052            container_name: String::new(),
4053        };
4054        wstore.agent_def_insert(&mut tpl).unwrap();
4055
4056        let mut user_a = AgentDefinition {
4057            id: "user-a".to_string(),
4058            slug: String::new(),
4059            name: "Maks".to_string(),
4060            icon: String::new(),
4061            provider: "claude".to_string(),
4062            description: String::new(),
4063            working_directory: String::new(),
4064            shell: String::new(),
4065            provider_flags: String::new(),
4066            auto_start: 0,
4067            restart_on_crash: 0,
4068            idle_timeout_minutes: 0,
4069            created_at: 1_700_000_001_000,
4070            agent_type: "host".to_string(),
4071            environment: String::new(),
4072            agent_bus_id: String::new(),
4073            is_seeded: 0,
4074            accounts: String::new(),
4075            parent_id: String::new(),
4076            branch_label: String::new(),
4077            updated_at: 1_700_000_001_000,
4078            user_hidden: 0,
4079            container_image: String::new(),
4080            container_volumes: "[]".to_string(),
4081            container_name: String::new(),
4082        };
4083        wstore.agent_def_insert(&mut user_a).unwrap();
4084
4085        let (engine, rx) = WshRpcEngine::new();
4086        super::register_agent_handlers(&engine, &state);
4087        (state, engine, rx)
4088    }
4089
4090    #[tokio::test]
4091    async fn listagents_no_filter_returns_all() {
4092        let (_state, engine, mut rx) = build_state_with_template_seed();
4093        let agents: Vec<AgentDefinition> = call_rpc(
4094            &engine,
4095            &mut rx,
4096            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4097            serde_json::json!({}),
4098        )
4099        .await;
4100        assert!(agents.iter().any(|a| a.id == "tpl-claude"));
4101        assert!(agents.iter().any(|a| a.id == "user-a"));
4102    }
4103
4104    #[tokio::test]
4105    async fn listagents_filter_templates_only() {
4106        let (_state, engine, mut rx) = build_state_with_template_seed();
4107        let agents: Vec<AgentDefinition> = call_rpc(
4108            &engine,
4109            &mut rx,
4110            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4111            serde_json::json!({ "is_seeded": 1 }),
4112        )
4113        .await;
4114        assert!(agents.iter().all(|a| a.is_seeded == 1));
4115        assert!(agents.iter().any(|a| a.id == "tpl-claude"));
4116        assert!(!agents.iter().any(|a| a.id == "user-a"));
4117    }
4118
4119    #[tokio::test]
4120    async fn listagents_filter_user_owned_only() {
4121        let (_state, engine, mut rx) = build_state_with_template_seed();
4122        let agents: Vec<AgentDefinition> = call_rpc(
4123            &engine,
4124            &mut rx,
4125            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4126            serde_json::json!({ "is_seeded": 0 }),
4127        )
4128        .await;
4129        assert!(agents.iter().all(|a| a.is_seeded == 0));
4130        assert!(agents.iter().any(|a| a.id == "user-a"));
4131        assert!(!agents.iter().any(|a| a.id == "tpl-claude"));
4132    }
4133
4134    #[tokio::test]
4135    async fn create_from_template_happy_path_clones_and_returns_id() {
4136        let (_state, engine, mut rx) = build_state_with_template_seed();
4137        let resp: crate::backend::rpc_types::AgentDefCreateFromTemplateResult = call_rpc(
4138            &engine,
4139            &mut rx,
4140            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
4141            serde_json::json!({
4142                "template_id": "tpl-claude",
4143                "name": "Asaf",
4144                "identity_id": "id-work",
4145                "memory_id": "mem-notes",
4146            }),
4147        )
4148        .await;
4149        assert!(!resp.definition_id.is_empty());
4150        assert_eq!(resp.identity_id, "id-work");
4151        assert_eq!(resp.memory_id, "mem-notes");
4152
4153        // The new row is user-owned, carries provider + flags from template.
4154        let agents: Vec<AgentDefinition> = call_rpc(
4155            &engine,
4156            &mut rx,
4157            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4158            serde_json::json!({}),
4159        )
4160        .await;
4161        let new_def = agents
4162            .iter()
4163            .find(|a| a.id == resp.definition_id)
4164            .expect("new definition should appear in listagents");
4165        assert_eq!(new_def.is_seeded, 0);
4166        assert_eq!(new_def.name, "Asaf");
4167        assert_eq!(new_def.provider, "claude");
4168        assert_eq!(new_def.provider_flags, "--model haiku");
4169        assert_eq!(new_def.parent_id, "tpl-claude");
4170    }
4171
4172    async fn call_rpc_expect_error(
4173        engine: &Arc<WshRpcEngine>,
4174        rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
4175        command: &str,
4176        data: serde_json::Value,
4177    ) -> String {
4178        let req_id = format!("test-{}", uuid::Uuid::new_v4());
4179        let msg = crate::backend::rpc_types::RpcMessage {
4180            command: command.to_string(),
4181            reqid: req_id.clone(),
4182            data: Some(data),
4183            ..Default::default()
4184        };
4185        engine.handle_message(msg);
4186        let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
4187            .await
4188            .expect("handler timed out")
4189            .expect("output channel closed");
4190        assert_eq!(resp.resid, req_id);
4191        assert!(
4192            !resp.error.is_empty(),
4193            "expected error, got success payload: {:?}",
4194            resp.data
4195        );
4196        resp.error
4197    }
4198
4199    #[tokio::test]
4200    async fn create_from_template_rejects_non_template_id() {
4201        let (_state, engine, mut rx) = build_state_with_template_seed();
4202        // "user-a" is is_seeded=0 — not a template.
4203        let err = call_rpc_expect_error(
4204            &engine,
4205            &mut rx,
4206            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
4207            serde_json::json!({
4208                "template_id": "user-a",
4209                "name": "another",
4210            }),
4211        )
4212        .await;
4213        assert!(
4214            err.contains("not a seeded template"),
4215            "wrong error: {err}"
4216        );
4217    }
4218
4219    #[tokio::test]
4220    async fn create_from_template_rejects_unknown_template_id() {
4221        let (_state, engine, mut rx) = build_state_with_template_seed();
4222        let err = call_rpc_expect_error(
4223            &engine,
4224            &mut rx,
4225            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
4226            serde_json::json!({
4227                "template_id": "no-such-id",
4228                "name": "x",
4229            }),
4230        )
4231        .await;
4232        assert!(err.contains("not found"), "wrong error: {err}");
4233    }
4234
4235    #[tokio::test]
4236    async fn create_from_template_rejects_duplicate_user_name() {
4237        let (_state, engine, mut rx) = build_state_with_template_seed();
4238        // "Maks" already exists as a user-owned agent.
4239        let err = call_rpc_expect_error(
4240            &engine,
4241            &mut rx,
4242            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
4243            serde_json::json!({
4244                "template_id": "tpl-claude",
4245                "name": "Maks",
4246            }),
4247        )
4248        .await;
4249        assert!(
4250            err.contains("already exists"),
4251            "wrong error: {err}"
4252        );
4253    }
4254
4255    #[tokio::test]
4256    async fn create_from_template_rejects_empty_name() {
4257        let (_state, engine, mut rx) = build_state_with_template_seed();
4258        let err = call_rpc_expect_error(
4259            &engine,
4260            &mut rx,
4261            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
4262            serde_json::json!({
4263                "template_id": "tpl-claude",
4264                "name": "   ",
4265            }),
4266        )
4267        .await;
4268        assert!(err.contains("non-empty"), "wrong error: {err}");
4269    }
4270
4271    // ---- Two-tier picker Phase 2: hide / unhide templates ----
4272    //
4273    // SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md Q2 Decision Y.
4274
4275    #[tokio::test]
4276    async fn hide_template_then_listagents_excludes_it_by_default() {
4277        let (_state, engine, mut rx) = build_state_with_template_seed();
4278
4279        // Before hide: template is in the default listagents result.
4280        let before: Vec<AgentDefinition> = call_rpc(
4281            &engine,
4282            &mut rx,
4283            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4284            serde_json::json!({}),
4285        )
4286        .await;
4287        assert!(before.iter().any(|a| a.id == "tpl-claude"));
4288
4289        // Hide the template.
4290        let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
4291            &engine,
4292            &mut rx,
4293            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
4294            serde_json::json!({ "definition_id": "tpl-claude" }),
4295        )
4296        .await;
4297        assert!(resp.ok);
4298
4299        // After hide: default listagents no longer surfaces it.
4300        let after: Vec<AgentDefinition> = call_rpc(
4301            &engine,
4302            &mut rx,
4303            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4304            serde_json::json!({}),
4305        )
4306        .await;
4307        assert!(
4308            !after.iter().any(|a| a.id == "tpl-claude"),
4309            "hidden template should NOT appear by default",
4310        );
4311
4312        // But user-owned rows (is_seeded=0) still appear — hide only
4313        // affects templates.
4314        assert!(after.iter().any(|a| a.id == "user-a"));
4315
4316        // include_hidden = true brings it back (settings panel surface).
4317        let included: Vec<AgentDefinition> = call_rpc(
4318            &engine,
4319            &mut rx,
4320            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4321            serde_json::json!({ "include_hidden": true }),
4322        )
4323        .await;
4324        let tpl = included
4325            .iter()
4326            .find(|a| a.id == "tpl-claude")
4327            .expect("hidden template should appear with include_hidden=true");
4328        assert_eq!(tpl.user_hidden, 1);
4329    }
4330
4331    #[tokio::test]
4332    async fn hide_then_unhide_round_trip() {
4333        let (_state, engine, mut rx) = build_state_with_template_seed();
4334        let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
4335            &engine,
4336            &mut rx,
4337            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
4338            serde_json::json!({ "definition_id": "tpl-claude" }),
4339        )
4340        .await;
4341        let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
4342            &engine,
4343            &mut rx,
4344            crate::backend::rpc_types::COMMAND_AGENT_DEF_UNHIDE,
4345            serde_json::json!({ "definition_id": "tpl-claude" }),
4346        )
4347        .await;
4348        assert!(resp.ok);
4349        // Listagents now shows it again, default-filter included.
4350        let agents: Vec<AgentDefinition> = call_rpc(
4351            &engine,
4352            &mut rx,
4353            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4354            serde_json::json!({}),
4355        )
4356        .await;
4357        let tpl = agents
4358            .iter()
4359            .find(|a| a.id == "tpl-claude")
4360            .expect("unhidden template should appear");
4361        assert_eq!(tpl.user_hidden, 0);
4362    }
4363
4364    #[tokio::test]
4365    async fn hide_rejects_user_owned_definition() {
4366        let (_state, engine, mut rx) = build_state_with_template_seed();
4367        // "user-a" is is_seeded=0 — hide must reject.
4368        let err = call_rpc_expect_error(
4369            &engine,
4370            &mut rx,
4371            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
4372            serde_json::json!({ "definition_id": "user-a" }),
4373        )
4374        .await;
4375        assert!(
4376            err.contains("not a seeded template"),
4377            "wrong error: {err}"
4378        );
4379    }
4380
4381    #[tokio::test]
4382    async fn hide_unknown_id_returns_ok_false() {
4383        let (_state, engine, mut rx) = build_state_with_template_seed();
4384        let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
4385            &engine,
4386            &mut rx,
4387            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
4388            serde_json::json!({ "definition_id": "no-such-id" }),
4389        )
4390        .await;
4391        assert!(!resp.ok);
4392    }
4393
4394    #[tokio::test]
4395    async fn list_hidden_templates_returns_only_hidden_templates() {
4396        let (_state, engine, mut rx) = build_state_with_template_seed();
4397        // Empty initially.
4398        let empty: Vec<AgentDefinition> = call_rpc(
4399            &engine,
4400            &mut rx,
4401            crate::backend::rpc_types::COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
4402            serde_json::json!({}),
4403        )
4404        .await;
4405        assert!(empty.is_empty());
4406
4407        // Hide one; expect it to surface.
4408        let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
4409            &engine,
4410            &mut rx,
4411            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
4412            serde_json::json!({ "definition_id": "tpl-claude" }),
4413        )
4414        .await;
4415        let hidden: Vec<AgentDefinition> = call_rpc(
4416            &engine,
4417            &mut rx,
4418            crate::backend::rpc_types::COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
4419            serde_json::json!({}),
4420        )
4421        .await;
4422        assert_eq!(hidden.len(), 1);
4423        assert_eq!(hidden[0].id, "tpl-claude");
4424        assert_eq!(hidden[0].is_seeded, 1);
4425        assert_eq!(hidden[0].user_hidden, 1);
4426    }
4427
4428    #[tokio::test]
4429    async fn listagents_is_seeded_filter_with_include_hidden_combines() {
4430        // Templates-only filter + include_hidden = the settings panel's
4431        // canonical query if it ever wanted the full template universe.
4432        // Without include_hidden + is_seeded=1 the hidden ones drop out.
4433        let (_state, engine, mut rx) = build_state_with_template_seed();
4434        let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
4435            &engine,
4436            &mut rx,
4437            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
4438            serde_json::json!({ "definition_id": "tpl-claude" }),
4439        )
4440        .await;
4441        let templates_visible: Vec<AgentDefinition> = call_rpc(
4442            &engine,
4443            &mut rx,
4444            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4445            serde_json::json!({ "is_seeded": 1 }),
4446        )
4447        .await;
4448        assert!(
4449            !templates_visible.iter().any(|a| a.id == "tpl-claude"),
4450            "hidden template should be excluded from is_seeded=1 default query",
4451        );
4452        let templates_all: Vec<AgentDefinition> = call_rpc(
4453            &engine,
4454            &mut rx,
4455            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
4456            serde_json::json!({ "is_seeded": 1, "include_hidden": true }),
4457        )
4458        .await;
4459        assert!(templates_all.iter().any(|a| a.id == "tpl-claude"));
4460    }
4461}