//! WebSocket connection handler — bidirectional JSON-RPC.
//!
//! Tool callback flow:
//! 1. Client submits proposal via proposal.submit
//! 2. Runtime encounters a ToolCall action
//! 3. WsToolExecutor sends tools.execute request to client via shared write half
//! 4. WsToolExecutor awaits response on a oneshot channel
//! 5. Client executes tool locally, sends JSON-RPC response back
//! 6. Handler receives the response, resolves the oneshot
//! 7. Runtime continues execution with the tool result

use crate::session::{A2aRouteAuth, ServerState, WsChannel};
use car_proto::*;
use car_verify;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::atomic::AtomicU64;
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tokio_tungstenite::{accept_async, tungstenite::Message};
use tracing::{info, instrument};

#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct JsonRpcMessage {
    #[serde(default)]
    pub jsonrpc: String,
    #[serde(default)]
    pub method: Option<String>,
    #[serde(default)]
    pub params: Value,
    #[serde(default)]
    pub id: Value,
    // Response fields
    #[serde(default)]
    pub result: Option<Value>,
    #[serde(default)]
    pub error: Option<Value>,
}

#[derive(Debug, Serialize)]
pub struct JsonRpcResponse {
    pub jsonrpc: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub result: Option<Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<JsonRpcError>,
    pub id: Value,
}

#[derive(Debug, Serialize)]
pub struct JsonRpcError {
    pub code: i32,
    pub message: String,
}

impl JsonRpcResponse {
    pub fn success(id: Value, result: Value) -> Self {
        Self {
            jsonrpc: "2.0",
            result: Some(result),
            error: None,
            id,
        }
    }
    pub fn error(id: Value, code: i32, message: &str) -> Self {
        Self {
            jsonrpc: "2.0",
            result: None,
            error: Some(JsonRpcError {
                code,
                message: message.to_string(),
            }),
            id,
        }
    }
}

/// Convenience wrapper for the standalone `car-server` binary: accepts
/// the WebSocket handshake on a raw [`TcpStream`] then delegates to
/// [`run_dispatch`]. Embedders that already have a handshake-completed
/// `WebSocketStream` skip this and call `run_dispatch` directly.
#[instrument(
    name = "ws.connection",
    skip_all,
    fields(peer = %peer),
)]
pub async fn handle_connection(
    stream: TcpStream,
    peer: SocketAddr,
    state: Arc<ServerState>,
) -> Result<(), Box<dyn std::error::Error>> {
    let ws_stream = accept_async(stream).await?;
    let (write, read) = ws_stream.split();
    run_dispatch(read, Box::pin(write), peer.to_string(), state).await
}

/// Convenience wrapper for the daemon-as-default Unix-socket
/// listener. Same shape as [`handle_connection`] but accepts a
/// `UnixStream` — used by the per-user UDS listener in
/// `car-server::main` (default transport for FFI thin clients,
/// since UDS is faster + permission-scoped vs localhost TCP).
///
/// Unix-only — `tokio::net::UnixStream` is gated on
/// `cfg(all(unix, feature = "net"))`. On Windows the daemon binds
/// only the TCP listener (loopback) and this entry point is
/// compiled out; consumers must use `handle_connection` instead.
#[cfg(unix)]
#[instrument(
    name = "ws.connection",
    skip_all,
    fields(peer = %peer),
)]
pub async fn handle_connection_unix(
    stream: tokio::net::UnixStream,
    peer: String,
    state: Arc<ServerState>,
) -> Result<(), Box<dyn std::error::Error>> {
    let ws_stream = tokio_tungstenite::accept_async(stream).await?;
    let (write, read) = ws_stream.split();
    run_dispatch(read, Box::pin(write), peer, state).await
}

fn handler_default_deadline_secs() -> u64 {
    std::env::var("CAR_HANDLER_TIMEOUT")
        .ok()
        .and_then(|s| s.trim().parse::<u64>().ok())
        .unwrap_or(1800)
}

/// Per-request server-side deadline. A wedged handler that never completes
/// otherwise hangs the calling client forever (try/catch can't catch a hang) —
/// the incident class this guards against. Policy errs toward NOT killing real
/// work:
///   - Genuinely UNBOUNDED methods (streams, subscriptions, run-loops, voice
///     turns) are EXEMPT (None) — they legitimately stay open indefinitely.
///   - Bounded, CANCELLATION-SAFE reads (list/get/query/count, auth/handshake)
///     get a tight deadline so a wedge recovers quickly — dropping a read
///     mid-flight can't corrupt state.
///   - EVERYTHING ELSE — including MUTATING memory ops (add_fact/persist/
///     consolidate, which mutate-then-persist) and all agentic/inference/
///     workflow/automation methods, and any method not explicitly classified —
///     gets a GENEROUS default. Safe: it's well above any legitimate duration
///     (the client itself caps proposal.submit at ~900s), so real work is never
///     killed, yet a true infinite wedge is still reaped. We do NOT pull
///     mutating handlers into the tight bucket, because a mid-flight drop there
///     could leave in-memory and on-disk state divergent. Override the default
///     via CAR_HANDLER_TIMEOUT (seconds).
///
/// NOTE: timeout cancels the handler future at an .await point — it reaps async
/// hangs (e.g. a lock().await that never acquires). It cannot preempt a handler
/// blocked inside a synchronous syscall with no .await; those need spawn_blocking.
// ---- Deadline policy table (auditable in one place; covered by tests below) ----
// Unbounded by design — streams/subscriptions/run-loops/voice turns. Any NEW
// indefinitely-blocking method MUST add its token here or it will inherit the
// generous default and be killed.
const DEADLINE_EXEMPT_SUBSTRINGS: &[&str] = &["stream", "subscribe", "run_loop", "dispatch_turn"];
// FAST = bounded, CANCELLATION-SAFE reads only. Mutating/heavy methods are
// deliberately omitted so they take the safe generous default — a mid-flight
// drop on a mutator could diverge in-memory vs on-disk state. The suffixes are a
// heuristic: any NEW .list/.get/.query/.count that mutates or does heavy I/O
// must be excluded (it then falls through to the safe default).
const DEADLINE_FAST_SUFFIXES: &[&str] = &[".list", ".get", ".query", ".count"];
const DEADLINE_FAST_PREFIXES: &[&str] = &["auth."];
const DEADLINE_FAST_EXACT: &[&str] = &[
    "agents.health",
    "server.handshake",
    "session.auth",
    "session.init",
];
const DEADLINE_FAST_SECS: u64 = 180;

// ---- WebSocket keepalive (server-side ping/liveness) ----
// tokio-tungstenite sends no automatic pings, so a silently-dead connection
// (TCP half-open, no close frame) on a QUIET stream is never noticed and its
// subscriber/registry state leaks until daemon restart. We ping periodically;
// any inbound frame (incl. the pong) refreshes the liveness clock; no frame for
// KEEPALIVE_DEAD_AFTER (or a failed ping write) → the connection is dead.
const KEEPALIVE_PING_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
const KEEPALIVE_DEAD_AFTER: std::time::Duration = std::time::Duration::from_secs(90); // ~3 missed pings
const KEEPALIVE_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); // matches drain task

fn server_deadline_for(method: &str) -> Option<std::time::Duration> {
    if DEADLINE_EXEMPT_SUBSTRINGS
        .iter()
        .any(|s| method.contains(s))
    {
        return None;
    }
    let fast = DEADLINE_FAST_SUFFIXES.iter().any(|s| method.ends_with(s))
        || DEADLINE_FAST_PREFIXES.iter().any(|p| method.starts_with(p))
        || DEADLINE_FAST_EXACT.contains(&method);
    let secs = if fast {
        DEADLINE_FAST_SECS
    } else {
        handler_default_deadline_secs()
    };
    Some(std::time::Duration::from_secs(secs))
}

#[cfg(test)]
mod deadline_policy_tests {
    use super::{handler_default_deadline_secs, server_deadline_for, DEADLINE_FAST_SECS};
    use std::time::Duration;

    #[test]
    fn classifies_methods_into_expected_buckets() {
        let default = Duration::from_secs(handler_default_deadline_secs());
        let fast = Duration::from_secs(DEADLINE_FAST_SECS);

        // Unbounded → exempt (no deadline).
        for m in [
            "infer_stream",
            "runs.subscribe",
            "scheduler.run_loop",
            "voice.dispatch_turn",
            "voice.tts_stream.start",
        ] {
            assert_eq!(server_deadline_for(m), None, "{m} should be exempt");
        }
        // Bounded, cancellation-safe reads → fast.
        for m in [
            "memory.query",
            "agents.list",
            "meeting.get",
            "runs.list",
            "agents.health",
            "server.handshake",
            "auth.status",
        ] {
            assert_eq!(server_deadline_for(m), Some(fast), "{m} should be fast");
        }
        // Mutating / heavy / agentic → generous default (NOT the tight bucket).
        for m in [
            "memory.persist",
            "memory.add_fact",
            "memory.consolidate",
            "proposal.submit",
            "workflow.run",
            "multi.swarm",
        ] {
            assert_eq!(
                server_deadline_for(m),
                Some(default),
                "{m} should take the generous default"
            );
        }
    }
}

/// Transport-neutral entry point: drives the JSON-RPC dispatch loop
/// against an already-handshake-completed split WebSocket. Generic
/// over the read half (any `Stream<Item = Result<Message, WsError>>`)
/// and the write half (a [`WsSink`] — type-erased so this function
/// doesn't templatize every downstream consumer of `WsChannel`).
///
/// `peer` is a free-form string ("127.0.0.1:1234" for TCP,
/// "uds:/path/sock" for UDS, "axum:..." for embedders) — used only
/// for tracing fields, never for dispatch logic.
#[instrument(
    name = "ws.dispatch",
    skip_all,
    fields(client_id = tracing::field::Empty, peer = %peer),
)]
pub async fn run_dispatch<R>(
    mut read: R,
    write: crate::session::WsSink,
    peer: String,
    state: Arc<ServerState>,
) -> Result<(), Box<dyn std::error::Error>>
where
    R: futures::Stream<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
        + Unpin
        + Send,
{
    let client_id = uuid::Uuid::new_v4().simple().to_string()[..12].to_string();
    tracing::Span::current().record("client_id", &client_id.as_str());

    info!("New connection from {}", peer);

    let channel = Arc::new(WsChannel {
        write: Mutex::new(write),
        pending: Mutex::new(HashMap::new()),
        next_id: AtomicU64::new(1),
    });

    let session = state.create_session(&client_id, channel.clone()).await;

    // car#209: per-request handlers are spawned detached (below) and
    // each clones `Arc<ClientSession>` → `Arc<WsChannel>`. A bare
    // `tokio::spawn` outlives the connection, so a slow/hung handler
    // pins the split sink and the inbound socket lingers in CLOSED
    // until the daemon hits EMFILE. Own them in a per-connection
    // `JoinSet` so every in-flight handler is aborted the instant the
    // WS drops (`abort_all` in the cleanup block; `JoinSet`'s Drop
    // also aborts on any early return), releasing the FD immediately.
    let mut conn_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();

    use futures::SinkExt; // for `channel.write.lock().await.send(..)` (keepalive ping)

    // Server-side WebSocket keepalive. See KEEPALIVE_* consts: tokio-tungstenite
    // emits no automatic pings, so a SILENTLY dead connection (TCP half-open, no
    // close frame) on a QUIET stream is never noticed — its drain task parks on
    // recv() and its registry entries leak until the daemon restarts. We ping on
    // an interval; a live client (even a quiet one) answers with a pong, which —
    // like any inbound frame — refreshes `last_inbound`. No frame for
    // KEEPALIVE_DEAD_AFTER, or a failed/timed-out ping write, means the peer is
    // gone → break into the SINGLE cleanup path below (deregisters subscribers,
    // aborts in-flight handlers). This is the "ping timeout" case the cleanup
    // comment anticipates.
    let mut keepalive = tokio::time::interval(KEEPALIVE_PING_INTERVAL);
    keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
    // `interval`'s first tick is immediate; consume it so the first ping fires a
    // full interval after connect (never racing a client's opening request).
    keepalive.tick().await;
    let mut last_inbound = tokio::time::Instant::now();
    loop {
        // Reap finished handlers so a long-lived connection doesn't
        // retain their `JoinHandle`s unbounded (memory, not FDs).
        while conn_tasks.try_join_next().is_some() {}

        // INVARIANT: every arm yields a `Message` or diverges (break/continue).
        // `read.next()` and `keepalive.tick()` are both cancellation-safe, so the
        // loser of each select is simply re-polled next iteration with no loss.
        let msg = tokio::select! {
            maybe = read.next() => match maybe {
                // Any inbound frame — text, binary, ping, or the pong our
                // keepalive provokes — proves the peer is alive.
                Some(Ok(m)) => {
                    last_inbound = tokio::time::Instant::now();
                    m
                }
                // car#209: on an abrupt transport error (peer reset / network
                // drop — the trader crash-loop case) `read.next()` yields
                // `Some(Err(_))`. The old `let msg = msg?;` propagated that
                // out of the function, *skipping the cleanup block below*, so
                // `state.sessions` (+ the host/a2ui/chat registries) kept the
                // session + channel — and thus the socket FD — forever. Break
                // instead: every disconnect, clean or not, runs the single
                // cleanup path. `None` = the stream ended cleanly.
                Some(Err(e)) => {
                    info!("read error from {}: {}; closing", client_id, e);
                    break;
                }
                None => break,
            },
            _ = keepalive.tick() => {
                if last_inbound.elapsed() >= KEEPALIVE_DEAD_AFTER {
                    info!(
                        "keepalive: no frames from {} for {}s; closing",
                        client_id,
                        last_inbound.elapsed().as_secs()
                    );
                    break;
                }
                // Provoke a pong from a quiet-but-alive client. The timeout wraps
                // the lock acquisition AND the send: `channel.write` is shared
                // with the dispatch handlers' `send_response` (an unbounded
                // write), so a handler wedged on a full TCP buffer can hold the
                // lock until its own 180s deadline — bounding only the send would
                // let the read loop park on `.lock().await` and defeat the 90s
                // liveness contract. Elapsed here covers a stuck lock or a stuck
                // socket; either way the peer is treated as gone.
                let sent = tokio::time::timeout(KEEPALIVE_WRITE_TIMEOUT, async {
                    let mut guard = channel.write.lock().await;
                    guard.send(Message::Ping(Vec::new().into())).await
                })
                .await;
                if !matches!(sent, Ok(Ok(()))) {
                    info!("keepalive: ping to {} failed/timed out; closing", client_id);
                    break;
                }
                continue;
            }
        };
        if msg.is_text() {
            let text = match msg.to_text() {
                Ok(t) => t,
                Err(e) => {
                    info!("non-text frame from {}: {}; closing", client_id, e);
                    break;
                }
            };
            let parsed: JsonRpcMessage = match serde_json::from_str(text) {
                Ok(m) => m,
                Err(e) => {
                    send_response(
                        &session.channel,
                        JsonRpcResponse::error(Value::Null, -32700, &format!("Parse error: {}", e)),
                    )
                    .await
                    .ok();
                    continue;
                }
            };

            // Is this a response to a pending tool callback?
            if parsed.method.is_none() && (parsed.result.is_some() || parsed.error.is_some()) {
                if let Some(id_str) = parsed.id.as_str() {
                    let mut pending = session.channel.pending.lock().await;
                    if let Some(tx) = pending.remove(id_str) {
                        let tool_resp = if let Some(result) = parsed.result {
                            ToolExecuteResponse {
                                action_id: id_str.to_string(),
                                output: Some(result),
                                error: None,
                            }
                        } else {
                            let err_msg = parsed
                                .error
                                .as_ref()
                                .and_then(|e| e.get("message"))
                                .and_then(|m| m.as_str())
                                .unwrap_or("unknown error")
                                .to_string();
                            ToolExecuteResponse {
                                action_id: id_str.to_string(),
                                output: None,
                                error: Some(err_msg),
                            }
                        };
                        let _ = tx.send(tool_resp);
                        continue;
                    }
                }
            }

            // Agent → host chat-event interceptor. `agent.chat.event`
            // notifications coming up the WS from a connected agent
            // are forwarded to the originating host's channel as
            // `agents.chat.event`. Lives ahead of the regular method
            // dispatch so the dispatcher doesn't reply with
            // "method-not-found" on what is a fire-and-forget
            // notification (no id). See
            // `docs/proposals/agent-chat-surface.md`.
            if try_forward_agent_chat_event(&parsed, &state).await {
                continue;
            }

            // Otherwise it's a client request
            if let Some(method) = &parsed.method {
                info!(method = %method, "dispatching JSON-RPC method");

                // Auth gate (Parslee-ai/car-releases#32). When the
                // server has an auth token installed, every method
                // other than `session.auth` is rejected on
                // unauthenticated sessions and the connection is
                // closed after the error response goes out. When no
                // token is installed (default), this branch never
                // fires — preserves pre-#32 behaviour.
                if state.auth_token.get().is_some()
                    && !session
                        .authenticated
                        .load(std::sync::atomic::Ordering::Acquire)
                    && method != "session.auth"
                {
                    let resp = JsonRpcResponse::error(
                        parsed.id.clone(),
                        -32001,
                        "auth required: send `session.auth` with the per-launch token \
                         from ~/Library/Application Support/ai.parslee.car/auth-token \
                         (macOS), $XDG_RUNTIME_DIR/ai.parslee.car/auth-token (Linux), \
                         or %LOCALAPPDATA%\\ai.parslee.car\\auth-token (Windows) \
                         as the first frame on this connection",
                    );
                    let _ = send_response(&session.channel, resp).await;
                    info!(client = %client_id, method = %method,
                        "rejecting non-auth method on unauthenticated session; closing");
                    break;
                }

                // Approval gate (audit 2026-05). High-risk methods —
                // anything that drives macOS automation or sends
                // messages on the user's behalf — must be acked by
                // the user via `host.resolve_approval` before they
                // dispatch. The gate raises an `approval.requested`
                // host event the local UI can render approve/deny on,
                // then parks until resolved or the configured
                // timeout fires. Returns a JSON-RPC error and
                // continues the dispatch loop on deny / timeout —
                // no connection close, since the caller may want to
                // retry with revised parameters.
                if state.approval_gate.requires_approval(method.as_str()) {
                    match gate_high_risk_method(method.as_str(), &parsed.params, &state).await {
                        Ok(()) => {}
                        Err(reason) => {
                            let resp = JsonRpcResponse::error(parsed.id.clone(), -32003, &reason);
                            let _ = send_response(&session.channel, resp).await;
                            info!(
                                client = %client_id,
                                method = %method,
                                reason = %reason,
                                "approval gate blocked dispatch"
                            );
                            continue;
                        }
                    }
                }

                // Spawn the per-method dispatch in a task so the read
                // loop keeps reading frames. Without this, methods
                // that trigger server-initiated `tools.execute`
                // callbacks (`proposal.submit`, `workflow.run`,
                // `multi.*` paths that fire a registered tool)
                // deadlock the connection: the handler awaits the
                // callback response on a oneshot, but the response is
                // another frame on this same read half — which the
                // synchronous `.await` here would prevent the loop
                // from ever picking up. Surfaced by the
                // `executeProposal: echo tool via JS callback` smoke
                // (#173). Response ordering becomes id-keyed (the
                // JSON-RPC demuxing contract) rather than
                // arrival-ordered.
                let session_task = session.clone();
                let state_task = state.clone();
                let method_owned = method.clone();
                let parsed_task = parsed;
                // car#209: owned by the per-connection JoinSet so it's
                // aborted on disconnect instead of leaking the channel.
                conn_tasks.spawn(async move {
                    let session = session_task;
                    let state = state_task;
                    let parsed = parsed_task;
                    // INVARIANT: arms must be pure tail expressions — no `?`,
                    // `return`, or `continue` — since they now live inside this
                    // `async` block, not the surrounding loop/fn.
                    let dispatch = async { match method_owned.as_str() {
                        "session.auth" => handle_session_auth(&parsed, &session, &state).await,
                        "parslee.auth" => handle_parslee_auth().await,
                        "parslee.capabilities" => {
                            crate::parslee_capabilities::discover().await
                        }
                        "parslee.m365.generate_document" => {
                            crate::parslee_m365::generate_document(&parsed.params).await
                        }
                        "auth.start" => handle_auth_start(&parsed).await,
                        "auth.complete" => handle_auth_complete(&parsed).await,
                        "auth.status" => handle_auth_status().await,
                        "auth.logout" => handle_auth_logout().await,
                        "session.init" => handle_session_init(&parsed, &session).await,
                        "session.bindSandbox" => {
                            handle_session_bind_sandbox(&parsed, &session, &state).await
                        }
                        "session.bindSubstrate" => {
                            handle_session_bind_substrate(&parsed, &session, &state).await
                        }
                        "host.subscribe" => handle_host_subscribe(&session, &state).await,
                        "host.agents" => handle_host_agents(&session).await,
                        "host.events" => handle_host_events(&parsed, &session).await,
                        "host.approvals" => handle_host_approvals(&session).await,
                        "host.register_agent" => {
                            handle_host_register_agent(&parsed, &session).await
                        }
                        "host.unregister_agent" => {
                            handle_host_unregister_agent(&parsed, &session).await
                        }
                        "host.set_status" => handle_host_set_status(&parsed, &session).await,
                        "host.notify" => handle_host_notify(&parsed, &session).await,
                        "host.request_approval" => {
                            handle_host_request_approval(&parsed, &session).await
                        }
                        "host.resolve_approval" => {
                            handle_host_resolve_approval(&parsed, &session).await
                        }
                        "tools.register" => handle_tools_register(&parsed, &session).await,
                        "tools.poll" => handle_tools_poll(&parsed, &session).await,
                        "tools.cancel" => handle_tools_cancel(&parsed, &session).await,
                        "tools.stream.subscribe" => {
                            handle_tools_stream_subscribe(&session).await
                        }
                        "proposal.submit" => {
                            handle_proposal_submit(&parsed, &session, &state).await
                        }
                        "policy.register" => handle_policy_register(&parsed, &session).await,
                        "session.policy.open" => handle_session_policy_open(&session).await,
                        "session.policy.close" => {
                            handle_session_policy_close(&parsed, &session).await
                        }
                        "verify" => handle_verify(&parsed, &session).await,
                        "permission.get_tier" => handle_permission_get_tier(&session).await,
                        "permission.set_tier" => {
                            handle_permission_set_tier(&parsed, &session, &state).await
                        }
                        "permission.classify" => {
                            handle_permission_classify(&parsed, &session).await
                        }
                        "permission.evaluate" => {
                            handle_permission_evaluate(&parsed, &session, &state).await
                        }
                        "permission.pending" => {
                            handle_permission_pending(&parsed, &session, &state).await
                        }
                        "permission.approve" => {
                            handle_permission_decision(&parsed, &session, &state, true).await
                        }
                        "permission.reject" => {
                            handle_permission_decision(&parsed, &session, &state, false).await
                        }
                        // Multi-device sync + execution lease (B6).
                        "sync.status" => handle_sync_status(&state).await,
                        "sync.append" => handle_sync_append(&parsed, &state).await,
                        "sync.record_turn" => handle_sync_record_turn(&parsed, &state).await,
                        "sync.record_intent" => handle_sync_record_intent(&parsed, &state).await,
                        "sync.pump" => handle_sync_pump(&state).await,
                        "sync.checkpoint" => handle_sync_checkpoint(&state).await,
                        "sync.rebase" => handle_sync_rebase(&state).await,
                        "sync.transcript" => handle_sync_transcript(&parsed, &state).await,
                        "sync.resume" => handle_sync_resume(&parsed, &state).await,
                        "sync.fence_check" => handle_sync_fence_check(&parsed, &state).await,
                        "lease.acquire" => handle_lease_acquire(&parsed, &state).await,
                        "lease.renew" => handle_lease_renew(&parsed, &state).await,
                        "lease.release" => handle_lease_release(&parsed, &state).await,
                        "lease.status" => handle_lease_status(&parsed, &state).await,
                        "messaging.config.get" => {
                            handle_messaging_config_get(&parsed, &session, &state).await
                        }
                        "messaging.config.set" => {
                            handle_messaging_config_set(&parsed, &session, &state).await
                        }
                        "messaging.pairing.start" => {
                            handle_messaging_pairing_start(&parsed, &session, &state).await
                        }
                        "messaging.pairing.status" => {
                            handle_messaging_pairing_status(&parsed, &session, &state).await
                        }
                        "messaging.status" => {
                            handle_messaging_status(&parsed, &session, &state).await
                        }
                        "messaging.test_send" => {
                            handle_messaging_test_send(&parsed, &session, &state).await
                        }
                        "state.get" => handle_state_get(&parsed, &session).await,
                        "state.set" => handle_state_set(&parsed, &session).await,
                        "state.exists" => handle_state_exists(&parsed, &session).await,
                        "state.keys" => handle_state_keys(&parsed, &session).await,
                        "state.snapshot" => handle_state_snapshot(&parsed, &session).await,
                        "memory.add_fact" => handle_memory_add_fact(&parsed, &session).await,
                        "memory.query" => handle_memory_query(&parsed, &session).await,
                        "memory.build_context" => {
                            handle_memory_build_context(&parsed, &session).await
                        }
                        "memory.build_context_fast" => {
                            handle_memory_build_context_fast(&parsed, &session).await
                        }
                        "memory.consolidate" => handle_memory_consolidate(&session).await,
                        "memory.utility_get" => handle_memory_utility_get(&session).await,
                        "memory.utility_set" => {
                            handle_memory_utility_set(&parsed, &session).await
                        }
                        "cascade.run" => handle_cascade_run(&parsed, &session, &state).await,
                        "evolution.plan" => {
                            handle_evolution_plan(&parsed, &session, &state).await
                        }
                        "evolution.run" => handle_evolution_run(&parsed, &session, &state).await,
                        "memory.fact_count" => handle_memory_fact_count(&session).await,
                        "memory.persist" => handle_memory_persist(&parsed, &session).await,
                        "memory.load" => handle_memory_load(&parsed, &session).await,
                        "skill.ingest" => handle_skill_ingest(&parsed, &session).await,
                        "skill.find" => handle_skill_find(&parsed, &session).await,
                        "skill.report" => handle_skill_report(&parsed, &session).await,
                        "skill.gate_deployment" => {
                            handle_skill_gate_deployment(&parsed, &session).await
                        }
                        "skill.enforce_deployment" => {
                            handle_skill_enforce_deployment(&parsed, &session, &state).await
                        }
                        "skill.ingest_governed" => {
                            handle_skill_ingest_governed(&parsed, &session, &state).await
                        }
                        "skill.repair" => handle_skill_repair(&parsed, &session).await,
                        "skills.ingest_distilled" => {
                            handle_skills_ingest_distilled(&parsed, &session).await
                        }
                        "skills.evolve" => handle_skills_evolve(&parsed, &session).await,
                        "skills.domains_needing_evolution" => {
                            handle_skills_domains_needing_evolution(&parsed, &session).await
                        }
                        "skills.ingest_provisional" => {
                            handle_skills_ingest_provisional(&parsed, &session).await
                        }
                        "skills.gate" => handle_skills_gate(&parsed, &session).await,
                        "skill.meta" => handle_skill_meta(&parsed, &session).await,
                        "skill.export" => handle_skill_export(&parsed, &session).await,
                        "skill.import" => handle_skill_import(&parsed, &session).await,
                        "multi.swarm" => handle_multi_swarm(&parsed, &session).await,
                        "multi.pipeline" => handle_multi_pipeline(&parsed, &session).await,
                        "multi.supervisor" => handle_multi_supervisor(&parsed, &session).await,
                        "multi.map_reduce" => handle_multi_map_reduce(&parsed, &session).await,
                        "multi.vote" => handle_multi_vote(&parsed, &session).await,
                        "multi.tournament" => handle_multi_tournament(&parsed, &session).await,
                        "multi.subtask" => handle_multi_subtask(&parsed, &session).await,
                        "scheduler.create" => handle_scheduler_create(&parsed),
                        "scheduler.run" => handle_scheduler_run(&parsed, &session).await,
                        "scheduler.run_loop" => handle_scheduler_run_loop(&parsed, &session).await,
                        "scheduler.os_render" => handle_scheduler_os_render(&parsed),
                        "scheduler.os_install" => handle_scheduler_os_install(&parsed),
                        "scheduler.os_uninstall" => handle_scheduler_os_uninstall(&parsed),
                        "scheduler.os_list" => handle_scheduler_os_list(&parsed),
                        "scheduler.os_reconcile" => handle_scheduler_os_reconcile(&parsed),
                        "infer" => handle_infer(&parsed, &state, &session).await,
                        "image.generate" => handle_image_generate(&parsed, &state).await,
                        "video.generate" => handle_video_generate(&parsed, &state).await,
                        "embed" => handle_embed(&parsed, &state).await,
                        "classify" => handle_classify(&parsed, &state).await,
                        "tokenize" => handle_tokenize(&parsed, &state).await,
                        "detokenize" => handle_detokenize(&parsed, &state).await,
                        "rerank" => handle_rerank(&parsed, &state).await,
                        "transcribe" => handle_transcribe(&parsed, &state).await,
                        "synthesize" => handle_synthesize(&parsed, &state).await,
                        "search" => handle_search(&parsed, &state).await,
                        "web_fetch" => handle_web_fetch(&parsed, &state).await,
                        "infer_stream" => handle_infer_stream(&parsed, &session, &state).await,
                        "speech.prepare" => handle_speech_prepare(&state).await,
                        "models.route" => handle_models_route(&parsed, &state).await,
                        "models.stats" => handle_models_stats(&state).await,
                        "outcomes.scoreboard" => handle_outcomes_scoreboard(&state).await,
                        "outcomes.resolve_pending" => {
                            handle_outcomes_resolve_pending(&parsed, &state).await
                        }
                        "events.count" => handle_events_count(&session).await,
                        "events.stats" => handle_events_stats(&session).await,
                        "events.truncate" => handle_events_truncate(&parsed, &session).await,
                        "events.clear" => handle_events_clear(&session).await,
                        "events.query" => handle_events_query(&parsed, &session).await,
                        "events.retention" => handle_events_retention(&parsed, &session).await,
                        "events.cost_by_agent" => handle_events_cost_by_agent(&session).await,
                        "events.chain.enable" => handle_events_chain_enable(&session).await,
                        "events.chain.verify" => handle_events_chain_verify(&session).await,
                        "metrics.summary" => handle_metrics_summary(&session).await,
                        "metrics.alerts" => handle_metrics_alerts(&parsed, &session).await,
                        "nlp.identify_language" => handle_nlp(&parsed, NlpOp::IdentifyLanguage),
                        "nlp.tokenize" => handle_nlp(&parsed, NlpOp::Tokenize),
                        "nlp.extract_entities" => handle_nlp(&parsed, NlpOp::ExtractEntities),
                        // Agent run tracing, U1 — run lifecycle bracket.
                        // `runs.start` mints a run_id and tags the
                        // session's current run BEFORE responding (KTD3);
                        // `runs.complete` records the terminal outcome.
                        // The per-turn recorder (U2) and the read/
                        // subscribe RPCs (U4/U5) build on these.
                        "runs.start" => handle_runs_start(&parsed, &session, &state).await,
                        "runs.complete" => {
                            handle_runs_complete(&parsed, &session, &state).await
                        }
                        // Client-narrated turn append (feedback-agent
                        // A2UI/runs plan, U1). WS-only (no FFI method, like
                        // runs.subscribe) — an out-of-pipeline agent whose
                        // work happens inside its own subprocess pushes full
                        // RunTurns it built itself. Authorized to the run's
                        // OWNING agent only (host-token/unbound writers are
                        // rejected as forgery); appends through the shared
                        // `record_run_turns` (index re-stamp, persist, fanout).
                        "runs.record_turns" => {
                            handle_runs_record_turns(&parsed, &session, &state).await
                        }
                        // Live run-trace subscribe/unsubscribe (U4). WS-only
                        // (no FFI method) — CarHost consumes the
                        // `runs.trace.event` notification. Authorization
                        // gated per-run (R16/KTD10).
                        "runs.subscribe" => {
                            handle_runs_subscribe(&parsed, &session, &state).await
                        }
                        "runs.unsubscribe" => {
                            handle_runs_unsubscribe(&parsed, &session, &state).await
                        }
                        // Replay reads (U5). WS-only (no FFI method, like
                        // `runs.subscribe`) — CarHost lists an agent's runs
                        // and fetches a completed run's full trace from the
                        // disk store (works across restart / client_id
                        // churn). Authorization gated per-agent/run (R16).
                        "runs.list" => handle_runs_list(&parsed, &session, &state).await,
                        "runs.get_trace" => {
                            handle_runs_get_trace(&parsed, &session, &state).await
                        }
                        "replan.set_config" => handle_replan_set_config(&parsed, &session).await,
                        "models.list" => handle_models_list(&state),
                        "models.register" => handle_models_register(&parsed, &state).await,
                        "models.unregister" => handle_models_unregister(&parsed, &state).await,
                        "models.list_unified" => handle_models_list_unified(&state),
                        "models.search" => handle_models_search(&parsed, &state),
                        "models.recommend" => handle_models_recommend(&parsed, &state),
                        "models.setup_plan" => handle_models_setup_plan(&parsed, &state),
                        "models.upgrades" => handle_models_upgrades(&state),
                        "models.detect_upgrades" => handle_models_detect_upgrades(&state).await,
                        "models.check_upgrade_nudge" => {
                            handle_models_check_upgrade_nudge(&parsed, &state).await
                        }
                        "models.dismiss_upgrade" => {
                            handle_models_dismiss_upgrade(&parsed, &state)
                        }
                        "models.check_concierge" => {
                            handle_models_check_concierge(&parsed, &state).await
                        }
                        "models.dismiss_suggestion" => {
                            handle_models_dismiss_suggestion(&parsed, &state)
                        }
                        // Concierge (Phase C1) — ambient status + labeled
                        // dismissal. WS-only; CarHost's Model Health pane +
                        // friction card consume these.
                        "concierge.status" => handle_concierge_status(&parsed, &state).await,
                        "concierge.dismiss" => handle_concierge_dismiss(&parsed, &state),
                        "concierge.defaults" => handle_concierge_defaults(&state),
                        "concierge.set_default" => {
                            handle_concierge_set_default(&parsed, &state).await
                        }
                        "concierge.clear_default" => {
                            handle_concierge_clear_default(&parsed, &state).await
                        }
                        "concierge.apply" => handle_concierge_apply(&parsed, &state).await,
                        "concierge.rollback" => handle_concierge_rollback(&parsed, &state).await,
                        "concierge.actions" => handle_concierge_actions(&parsed, &state),
                        "concierge.refresh_catalog" => {
                            handle_concierge_refresh_catalog(&state).await
                        }
                        "concierge.ask" => handle_concierge_ask(&parsed, &state).await,
                        "models.update_prefs_get" => handle_models_update_prefs_get(&state),
                        "models.update_prefs_set" => {
                            handle_models_update_prefs_set(&parsed, &state)
                        }
                        "models.pull" => handle_models_pull(&parsed, &state).await,
                        "models.install" => handle_models_pull(&parsed, &state).await,
                        "skills.distill" => handle_skills_distill(&parsed, &state).await,
                        "skills.list" => handle_skills_list(&parsed, &session).await,
                        "browser.run" => handle_browser_run(&parsed, &session).await,
                        "browser.close" => handle_browser_close(&session).await,
                        "secret.put" => handle_secret_put(&parsed),
                        "secret.get" => handle_secret_get(&parsed),
                        "secret.delete" => handle_secret_delete(&parsed),
                        "secret.status" => handle_secret_status(&parsed),
                        "secret.available" => Ok(car_ffi_common::secrets::is_available()),
                        "secret.list" => car_ffi_common::secrets::list(),
                        "permissions.status" => handle_perm_status(&parsed),
                        "permissions.request" => handle_perm_request(&parsed),
                        "permissions.explain" => handle_perm_explain(&parsed),
                        "permissions.domains" => Ok(car_ffi_common::permissions::domains()),
                        "accounts.list" => car_ffi_common::accounts::list(),
                        "accounts.open" => {
                            #[derive(serde::Deserialize, Default)]
                            struct OpenParams {
                                #[serde(default)]
                                account_id: Option<String>,
                            }
                            let p: OpenParams =
                                serde_json::from_value(parsed.params.clone()).unwrap_or_default();
                            car_ffi_common::accounts::open_settings(p.account_id.as_deref())
                        }
                        "calendar.list" => car_ffi_common::integrations::calendar_list(),
                        "calendar.events" => handle_calendar_events(&parsed),
                        "calendar.create_event" => handle_calendar_create_event(&parsed),
                        "calendar.update_event" => handle_calendar_update_event(&parsed),
                        "calendar.delete_event" => handle_calendar_delete_event(&parsed),
                        "contacts.containers" => {
                            car_ffi_common::integrations::contacts_containers()
                        }
                        "contacts.find" => handle_contacts_find(&parsed),
                        "mail.accounts" => car_ffi_common::integrations::mail_accounts(),
                        "mail.inbox" => handle_mail_inbox(&parsed),
                        "mail.send" => handle_mail_send(&parsed),
                        "messages.services" => car_ffi_common::integrations::messages_services(),
                        "messages.chats" => handle_messages_chats(&parsed),
                        "messages.send" => handle_messages_send(&parsed),
                        "notes.accounts" => car_ffi_common::integrations::notes_accounts(),
                        "notes.find" => handle_notes_find(&parsed),
                        "reminders.lists" => car_ffi_common::integrations::reminders_lists(),
                        "reminders.items" => handle_reminders_items(&parsed),
                        "photos.albums" => car_ffi_common::integrations::photos_albums(),
                        "bookmarks.list" => handle_bookmarks_list(&parsed),
                        "files.locations" => car_ffi_common::integrations::files_locations(),
                        "keychain.status" => car_ffi_common::integrations::keychain_status(),
                        "health.status" => car_ffi_common::health::status(),
                        "health.sleep" => handle_health_sleep(&parsed),
                        "health.workouts" => handle_health_workouts(&parsed),
                        "health.activity" => handle_health_activity(&parsed),
                        "voice.transcribe_stream.start" => {
                            handle_voice_transcribe_stream_start(&parsed, &state, &session).await
                        }
                        "voice.transcribe_stream.stop" => {
                            handle_voice_transcribe_stream_stop(&parsed, &state).await
                        }
                        "voice.transcribe_stream.push" => {
                            handle_voice_transcribe_stream_push(&parsed, &state).await
                        }
                        "voice.tts_stream.start" => {
                            handle_voice_tts_stream_start(&parsed, &session).await
                        }
                        "voice.tts_stream.cancel" => handle_voice_tts_stream_cancel(&parsed).await,
                        "voice.tts_stream.list" => Ok(handle_voice_tts_stream_list()),
                        "voice.sessions.list" => Ok(handle_voice_sessions_list(&state)),
                        "voice.dispatch_turn" => {
                            handle_voice_dispatch_turn(&parsed, &state, &session).await
                        }
                        "voice.cancel_turn" => handle_voice_cancel_turn().await,
                        "voice.prewarm_turn" => handle_voice_prewarm_turn(&state).await,
                        "inference.register_runner" => {
                            handle_inference_register_runner(&session).await
                        }
                        "inference.runner.event" => handle_inference_runner_event(&parsed).await,
                        "inference.runner.complete" => {
                            handle_inference_runner_complete(&parsed).await
                        }
                        "inference.runner.fail" => handle_inference_runner_fail(&parsed).await,
                        "voice.providers.list" => {
                            // Stateless: enumerates STT/TTS providers compiled into
                            // this build. Runtime readiness (API key, permission,
                            // model download) is reported via per-provider errors.
                            serde_json::from_str::<serde_json::Value>(
                                &car_voice::list_voice_providers_json(),
                            )
                            .map_err(|e| e.to_string())
                        }
                        "voice.prepare_parakeet" => car_ffi_common::voice::prepare_parakeet()
                            .await
                            .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
                        "voice.prepare_diarizer" => car_ffi_common::voice::prepare_diarizer()
                            .await
                            .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
                        "voice.enroll_speaker" => handle_enroll_speaker(&parsed).await,
                        "voice.list_enrollments" => car_ffi_common::voice::list_enrollments()
                            .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
                        "voice.remove_enrollment" => handle_remove_enrollment(&parsed),
                        "workflow.run" => handle_workflow_run(&parsed, &session).await,
                        "workflow.chain" => handle_workflow_chain(&parsed, &session).await,
                        "workflow.resume" => handle_workflow_resume(&parsed, &session).await,
                        "workflow.list_paused" => handle_workflow_list_paused().await,
                        "builder.build" => handle_builder_build(&parsed, &state, &session).await,
                        "workflow.verify" => handle_workflow_verify(&parsed),
                        "workflow.build_automation" => handle_workflow_build_automation(&parsed),
                        "meeting.start" => handle_meeting_start(&parsed, &state, &session).await,
                        "meeting.stop" => handle_meeting_stop(&parsed, &state, &session).await,
                        "meeting.list" => handle_meeting_list(&parsed),
                        "meeting.get" => handle_meeting_get(&parsed),
                        "registry.register" => handle_registry_register(&parsed),
                        "registry.heartbeat" => handle_registry_heartbeat(&parsed),
                        "registry.unregister" => handle_registry_unregister(&parsed),
                        "registry.list" => handle_registry_list(&parsed),
                        "registry.reap" => handle_registry_reap(&parsed),
                        "admission.status" => handle_admission_status(&state),
                        "a2a.start" => handle_a2a_start(&parsed, &state, &session).await,
                        "a2a.stop" => handle_a2a_stop(),
                        "a2a.status" => handle_a2a_status(),
                        "a2a.send" => handle_a2a_send(&parsed, &state).await,
                        "a2a.peers.add" => handle_a2a_peers_add(&parsed),
                        "a2a.peers.list" => handle_a2a_peers_list(),
                        "a2a.peers.remove" => handle_a2a_peers_remove(&parsed),
                        "a2ui.apply" => handle_a2ui_apply(&parsed, &state).await,
                        "a2ui.ingest" => handle_a2ui_ingest(&parsed, &state).await,
                        "a2ui.capabilities" => handle_a2ui_capabilities(&state),
                        "a2ui.reap" => handle_a2ui_reap(&state).await,
                        "a2ui.surfaces" => handle_a2ui_surfaces(&state).await,
                        "a2ui.get" => handle_a2ui_get(&parsed, &state).await,
                        "a2ui.action" => handle_a2ui_action(&parsed, &state).await,
                        "a2ui.render_report" => handle_a2ui_render_report(&parsed, &state).await,
                        "a2ui/subscribe" => handle_a2ui_subscribe(&session, &state).await,
                        "a2ui/unsubscribe" => handle_a2ui_unsubscribe(&session, &state).await,
                        "a2ui/replay" => handle_a2ui_replay(&parsed, &state).await,
                        "automation.run_applescript" => handle_run_applescript(&parsed).await,
                        "automation.shortcuts.list" => handle_list_shortcuts(&parsed).await,
                        "automation.shortcuts.run" => handle_run_shortcut(&parsed).await,
                        "notifications.local" => handle_local_notification(&parsed).await,
                        "vision.ocr" => handle_vision_ocr(&parsed).await,
                        "coder.start" => {
                            crate::coder::rpc::handle_coder_start(&parsed, &state).await
                        }
                        "coder.projects.list" => {
                            crate::coder::rpc::handle_coder_projects_list(&state).await
                        }
                        "coder.projects.create" => {
                            crate::coder::rpc::handle_coder_projects_create(&parsed, &state).await
                        }
                        "coder.projects.get" => {
                            crate::coder::rpc::handle_coder_projects_get(&parsed, &state).await
                        }
                        "coder.confirm_contract" => {
                            crate::coder::rpc::handle_coder_confirm_contract(&parsed, &state).await
                        }
                        "coder.list" => crate::coder::rpc::handle_coder_list(&state).await,
                        "coder.get" => crate::coder::rpc::handle_coder_get(&parsed, &state).await,
                        "coder.subscribe" => {
                            crate::coder::rpc::handle_coder_subscribe(&parsed, &state, &session)
                                .await
                        }
                        "coder.unsubscribe" => {
                            crate::coder::rpc::handle_coder_unsubscribe(&parsed, &state, &session)
                                .await
                        }
                        "coder.respond" => {
                            crate::coder::rpc::handle_coder_respond(&parsed, &state).await
                        }
                        "coder.approve_merge" => {
                            crate::coder::rpc::handle_coder_approve_merge(&parsed, &state).await
                        }
                        "coder.cancel" => {
                            crate::coder::rpc::handle_coder_cancel(&parsed, &state).await
                        }
                        "declagents.list" => {
                            crate::coder::rpc::handle_declagents_list(&state).await
                        }
                        "declagents.get" => {
                            crate::coder::rpc::handle_declagents_get(&parsed, &state).await
                        }
                        "declagents.remove" => {
                            crate::coder::rpc::handle_declagents_remove(&parsed, &state).await
                        }
                        "declagents.set_enabled" => {
                            crate::coder::rpc::handle_declagents_set_enabled(&parsed, &state).await
                        }
                        "declagents.invoke" => {
                            crate::coder::rpc::handle_declagents_invoke(&parsed, &state).await
                        }
                        "declagents.route" => {
                            crate::coder::rpc::handle_declagents_route(&parsed, &state).await
                        }
                        "declagents.route_split" => {
                            crate::coder::rpc::handle_declagents_route_split(&parsed, &state).await
                        }
                        "declagents.routing_stats" => {
                            crate::coder::rpc::handle_declagents_routing_stats(&state).await
                        }
                        "discovery.resolve" => {
                            crate::coder::rpc::handle_discovery_resolve(&parsed, &state).await
                        }
                        "discovery.route_compose" => {
                            crate::coder::rpc::handle_discovery_route_compose(&parsed, &state).await
                        }
                        "discovery.report" => {
                            crate::coder::rpc::handle_discovery_report(&parsed, &state).await
                        }
                        "agents.list" => handle_agents_list(&state).await,
                        "agents.health" => handle_agents_health(&state).await,
                        "agents.upsert" => handle_agents_upsert(&parsed, &state).await,
                        "agents.install" => handle_agents_install(&parsed, &state).await,
                        "agents.remove" => handle_agents_remove(&parsed, &state).await,
                        "agents.start" => handle_agents_start(&parsed, &state).await,
                        "agents.stop" => handle_agents_stop(&parsed, &state).await,
                        "agents.restart" => handle_agents_restart(&parsed, &state).await,
                        "agents.wait" => handle_agents_wait(&parsed, &state).await,
                        "agents.tail_log" => handle_agents_tail_log(&parsed, &state).await,
                        "agents.list_external" => handle_agents_list_external(&parsed).await,
                        "agents.detect_external" => handle_agents_detect_external(&parsed).await,
                        "agents.health_external" => handle_agents_health_external(&parsed).await,
                        "assistants.invoke" => {
                            handle_assistants_invoke(&parsed, &state, &session).await
                        }
                        "agents.invoke_external" => {
                            handle_agents_invoke_external(&parsed, &state, &session).await
                        }
                        "agents.chat" => handle_agents_chat(&parsed, &state, &session).await,
                        "agents.chat.cancel" => handle_agents_chat_cancel(&parsed, &state).await,
                        // Foreman: decompose a coding goal into a footprint-
                        // annotated, scheduled subtask plan (B6). Execution
                        // (foreman.run) farms the plan to external CLIs and gates
                        // the union — added once the run surface lands.
                        "foreman.plan" => handle_foreman_plan(&parsed, &state).await,
                        "foreman.run" => handle_foreman_run(&parsed, &state, &session).await,
                        // Remote MCP connectors (CAR as MCP client). WS-only
                        // (no FFI surface in Phase 1) — connector lifecycle is
                        // daemon-shared and interactive. See
                        // docs/proposals/remote-mcp-connectors.md.
                        "connectors.add" => handle_connectors_add(&parsed, &state).await,
                        "connectors.add_stdio" => {
                            handle_connectors_add_stdio(&parsed, &state).await
                        }
                        "connectors.authenticate" => {
                            handle_connectors_authenticate(&parsed, &state).await
                        }
                        "connectors.complete_authentication" => {
                            handle_connectors_complete_authentication(&parsed, &state).await
                        }
                        "connectors.list" => handle_connectors_list(&state).await,
                        "connectors.tools" => handle_connectors_tools(&parsed, &state).await,
                        "connectors.enable_tools" => {
                            handle_connectors_enable_tools(&parsed, &state).await
                        }
                        "connectors.disable_tools" => {
                            handle_connectors_disable_tools(&parsed, &state).await
                        }
                        "connectors.refresh" => handle_connectors_refresh(&parsed, &state).await,
                        "connectors.remove" => handle_connectors_remove(&parsed, &state).await,
                        // A2A v1.0 (PascalCase) + v0.3 (slash form) — both
                        // alias to the same in-core dispatcher per
                        // Parslee-ai/car-releases#28. Embedders that need a
                        // custom AgentCardSource / TaskStore plug them in
                        // via ServerStateConfig::with_a2a_card_source /
                        // with_a2a_store before any handler runs.
                        "message/send"
                        | "SendMessage"
                        | "message/stream"
                        | "SendStreamingMessage"
                        | "tasks/get"
                        | "GetTask"
                        | "tasks/list"
                        | "ListTasks"
                        | "tasks/cancel"
                        | "CancelTask"
                        | "tasks/resubscribe"
                        | "SubscribeToTask"
                        | "tasks/pushNotificationConfig/set"
                        | "CreateTaskPushNotificationConfig"
                        | "tasks/pushNotificationConfig/get"
                        | "GetTaskPushNotificationConfig"
                        | "tasks/pushNotificationConfig/list"
                        | "ListTaskPushNotificationConfigs"
                        | "tasks/pushNotificationConfig/delete"
                        | "DeleteTaskPushNotificationConfig"
                        | "agent/getAuthenticatedExtendedCard"
                        | "GetExtendedAgentCard" => {
                            handle_a2a_dispatch(method_owned.as_str(), &parsed, &state).await
                        }
                        "server.handshake" => handle_server_handshake(&parsed),
                        _ => Err(format!("unknown method: {}", method_owned)),
                    } };

                    // Per-request deadline so a wedged handler can't hang the
                    // client forever. None = exempt (unbounded streams/subs/loops).
                    // CAVEAT: on timeout the handler future is dropped, which
                    // (unlike the callback-timeout path) does NOT fire the
                    // tools.cancel host-abort (car#264) — a wedged in-flight tool
                    // callback may orphan a host child + leak one `pending` entry.
                    // Acceptable only because this fires solely on a genuine wedge
                    // at the generous deadline.
                    let result = match server_deadline_for(method_owned.as_str()) {
                        Some(deadline) => match tokio::time::timeout(deadline, dispatch).await {
                            Ok(r) => r,
                            Err(_) => Err(format!(
                                "handler '{}' exceeded the server-side deadline of {}s and was abandoned",
                                method_owned,
                                deadline.as_secs()
                            )),
                        },
                        None => dispatch.await,
                    };

                    let resp = match result {
                        Ok(value) => JsonRpcResponse::success(parsed.id, value),
                        Err(e) => JsonRpcResponse::error(parsed.id, -32603, &e),
                    };
                    let _ = send_response(&session.channel, resp).await;
                });
            }
        } else if msg.is_binary() {
            // CAR binary frame transport — see `car_ffi_common::voice::binary`
            // for the canonical header definition. 26-byte fixed header
            // followed by an opaque payload. Inbound type 0x01 carries
            // 16-bit signed LE PCM into a `pcm_push` session; other
            // types (0x02 TTS chunk, 0x03 final marker, 0x04 error)
            // are server-emitted and rejected here.
            let bytes = msg.into_data();
            let parsed = match car_ffi_common::voice::binary::parse_frame(&bytes) {
                Ok(p) => p,
                Err(e) => {
                    tracing::warn!("binary frame from {} rejected: {}", client_id, e);
                    continue;
                }
            };
            match parsed.frame_type {
                car_ffi_common::voice::binary::FRAME_TYPE_INBOUND_PCM => {
                    let registry = state.voice_sessions.clone();
                    let payload_owned = parsed.payload.to_vec();
                    let session_id_owned = parsed.session_id_hex.clone();
                    conn_tasks.spawn(async move {
                        if let Err(e) = car_ffi_common::voice::transcribe_stream_push(
                            &session_id_owned,
                            &payload_owned,
                            registry,
                        )
                        .await
                        {
                            tracing::warn!(
                                "binary PCM push to session {} failed: {}",
                                session_id_owned,
                                e
                            );
                        }
                    });
                }
                other => {
                    tracing::debug!(
                        "binary frame type {:#04x} from {} not accepted server-side",
                        other,
                        client_id
                    );
                }
            }
        } else if msg.is_close() {
            info!("Client {} disconnected", client_id);
            break;
        }
    }

    // car#209: abort every in-flight handler for this connection
    // *first* — they each hold an `Arc<ClientSession>` → `Arc<WsChannel>`
    // clone; until they're gone the split sink (and the inbound socket
    // FD) can't drop, even after the registries below are cleared.
    conn_tasks.abort_all();

    session.host.unsubscribe(&client_id).await;
    // Auto-cancel this session's pending approvals so the queue stays
    // in sync with what's actually decidable — covers graceful
    // unregister+close, hard crash (TCP reset), and ping timeout in
    // one place. System-level gate approvals (client_id None) are not
    // touched. car-releases#48.
    session.host.reap_session_approvals(&client_id).await;
    state.a2ui_subscribers.lock().await.remove(&client_id);

    // Fix for MULTI-4 / WS-3: drop the session from the registry and
    // drain any pending tool callbacks. Without this, every connection
    // we ever accepted keeps an `Arc<ClientSession>` alive in
    // `state.sessions`, and outstanding `oneshot::Sender`s in
    // `session.channel.pending` outlive the closed connection until
    // their per-call timeout (the action budget, or `DEFAULT_TOOL_TIMEOUT_MS`
    // — no longer a hardcoded 60s, see car#259). Dropping the senders here causes any
    // awaiting `recv()` in `WsToolExecutor::execute` to return
    // `RecvError` immediately, which the existing error-handler path
    // already maps to "callback channel closed" — same shape as the
    // timeout path, just faster.
    let _removed = state.remove_session(&client_id).await;
    {
        let mut pending = session.channel.pending.lock().await;
        pending.clear();
    }

    Ok(())
}

async fn send_response(
    channel: &WsChannel,
    resp: JsonRpcResponse,
) -> Result<(), Box<dyn std::error::Error>> {
    use futures::SinkExt;
    let json = serde_json::to_string(&resp)?;
    channel
        .write
        .lock()
        .await
        .send(Message::Text(json.into()))
        .await?;
    Ok(())
}

// --- Request handlers ---

async fn handle_host_subscribe(
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    session
        .host
        .subscribe(&session.client_id, session.channel.clone())
        .await;
    serde_json::to_value(HostSnapshot {
        subscribed: true,
        agents: session.host.agents().await,
        approvals: session.host.approvals().await,
        events: session.host.events(50).await,
        identity: Some(daemon_identity(state)),
    })
    .map_err(|e| e.to_string())
}

/// Snapshot the daemon-identity facts for a fresh subscriber.
/// Cheap: non-acquiring reads on `OnceLock`s + a single
/// `to_string_lossy` on the manifest path. Critically uses
/// [`ServerState::supervisor_if_installed`] — not the lazy-init
/// `supervisor()` — so a Heisenberg subscribe can't *cause* the
/// daemon to acquire the manifest lock just by asking whether it
/// owns one.
fn daemon_identity(state: &Arc<ServerState>) -> car_proto::HostIdentity {
    // Observer takes precedence: when both supervisor and observer
    // markers are set (currently unreachable through the standalone
    // binary, but an embedder could install both racily), the
    // observer marker is the authoritative role since the
    // supervisor handle is only installed when this daemon owns
    // the lock.
    let (manifest_path, manifest_role) = if let Some(p) = state.observer_manifest_path() {
        (
            Some(p.to_string_lossy().into_owned()),
            car_proto::HostManifestRole::Observer,
        )
    } else if let Some(s) = state.supervisor_if_installed() {
        (
            Some(s.manifest_path().to_string_lossy().into_owned()),
            car_proto::HostManifestRole::Owner,
        )
    } else {
        (None, car_proto::HostManifestRole::None)
    };
    car_proto::HostIdentity {
        version: env!("CARGO_PKG_VERSION").to_string(),
        pid: std::process::id(),
        manifest_path,
        manifest_role,
        parslee: state
            .parslee_session
            .get()
            .map(|session| session.identity.clone()),
    }
}

/// Return the Parslee cloud credential for an authenticated CAR
/// connection. Managed agents already receive `CAR_AUTH_TOKEN` and
/// authenticate to the local daemon with `session.auth`; this method is
/// their supported bridge from local CAR auth to the user's Parslee
/// backend auth.
///
/// The bearer token is intentionally not injected into every managed
/// child process environment. Agents ask for it only when they need it,
/// through the same local auth gate that protects the rest of the
/// daemon.
async fn handle_parslee_auth() -> Result<Value, String> {
    let session = crate::parslee_auth::load_or_refresh()
        .await?
        .ok_or_else(|| "Parslee account not authenticated; run `car auth login`".to_string())?;
    Ok(serde_json::json!({
        "authenticated": true,
        "token_type": "Bearer",
        "access_token": session.access_token,
        "authorization_header": format!("Bearer {}", session.access_token),
        "identity": session.identity,
    }))
}

// --- auth.* : GUI-driven Parslee sign-in (CAR Host.app).
// Shares one implementation with `car auth login` via the car-auth
// crate. Stateless: the trusted in-process GUI holds the PKCE
// verifier + state between auth.start and auth.complete (same trust
// boundary as the keychain it ultimately writes).
async fn handle_auth_start(req: &JsonRpcMessage) -> Result<Value, String> {
    let api_base = car_auth::api_base(req.params.get("api_base").and_then(|v| v.as_str()));
    let client_id = req
        .params
        .get("client_id")
        .and_then(|v| v.as_str())
        .unwrap_or("parslee-car");
    let redirect_uri = req
        .params
        .get("redirect_uri")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "redirect_uri is required".to_string())?;
    let provider = req.params.get("provider").and_then(|v| v.as_str());
    let state = car_auth::new_state();
    let verifier = car_auth::pkce_verifier();
    let challenge = car_auth::pkce_challenge(&verifier);
    let url = car_auth::authorize_url(
        &api_base,
        client_id,
        redirect_uri,
        &state,
        &challenge,
        provider,
    )?;
    Ok(serde_json::json!({
        "authorize_url": url,
        "state": state,
        "verifier": verifier,
    }))
}

async fn handle_auth_complete(req: &JsonRpcMessage) -> Result<Value, String> {
    let api_base = car_auth::api_base(req.params.get("api_base").and_then(|v| v.as_str()));
    let client_id = req
        .params
        .get("client_id")
        .and_then(|v| v.as_str())
        .unwrap_or("parslee-car");
    let redirect_uri = req
        .params
        .get("redirect_uri")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "redirect_uri is required".to_string())?;
    let code = req
        .params
        .get("code")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "code is required".to_string())?;
    let verifier = req
        .params
        .get("verifier")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "verifier is required".to_string())?;
    let token = car_auth::exchange_code(&api_base, client_id, redirect_uri, code, verifier).await?;
    car_auth::store_tokens(&api_base, &token)?;
    Ok(serde_json::json!({ "ok": true }))
}

async fn handle_auth_status() -> Result<Value, String> {
    match car_auth::fetch_status(None).await? {
        Some(session_json) => {
            let session: Value = serde_json::from_str(&session_json).unwrap_or(Value::Null);
            Ok(serde_json::json!({ "authenticated": true, "session": session }))
        }
        None => Ok(serde_json::json!({ "authenticated": false })),
    }
}

async fn handle_auth_logout() -> Result<Value, String> {
    car_auth::clear_tokens()?;
    Ok(serde_json::json!({ "ok": true }))
}

async fn handle_host_agents(session: &crate::session::ClientSession) -> Result<Value, String> {
    serde_json::to_value(session.host.agents().await).map_err(|e| e.to_string())
}

async fn handle_host_events(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let limit = req
        .params
        .get("limit")
        .and_then(|v| v.as_u64())
        .unwrap_or(100) as usize;
    serde_json::to_value(session.host.events(limit).await).map_err(|e| e.to_string())
}

async fn handle_host_approvals(session: &crate::session::ClientSession) -> Result<Value, String> {
    serde_json::to_value(session.host.approvals().await).map_err(|e| e.to_string())
}

async fn handle_a2ui_apply(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    #[derive(Deserialize)]
    struct Params {
        #[serde(default)]
        envelope: Option<car_a2ui::A2uiEnvelope>,
        #[serde(default)]
        message: Option<car_a2ui::A2uiEnvelope>,
    }

    let envelope = if req.params.get("createSurface").is_some()
        || req.params.get("updateComponents").is_some()
        || req.params.get("updateDataModel").is_some()
        || req.params.get("deleteSurface").is_some()
    {
        serde_json::from_value::<car_a2ui::A2uiEnvelope>(req.params.clone())
            .map_err(|e| e.to_string())?
    } else {
        match serde_json::from_value::<Params>(req.params.clone()) {
            Ok(params) => params
                .envelope
                .or(params.message)
                .ok_or_else(|| "`a2ui.apply` requires an A2UI envelope".to_string())?,
            Err(_) => serde_json::from_value::<car_a2ui::A2uiEnvelope>(req.params.clone())
                .map_err(|e| e.to_string())?,
        }
    };

    apply_a2ui_envelope(state, envelope, None, None).await
}

async fn handle_a2ui_ingest(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    #[derive(Deserialize)]
    #[serde(rename_all = "camelCase")]
    struct Params {
        #[serde(default)]
        endpoint: Option<String>,
        #[serde(default)]
        a2a_endpoint: Option<String>,
        #[serde(default)]
        owner: Option<car_a2ui::A2uiSurfaceOwner>,
        #[serde(default)]
        route_auth: Option<A2aRouteAuth>,
        #[serde(default)]
        allow_untrusted_endpoint: bool,
    }

    let params = serde_json::from_value::<Params>(req.params.clone()).unwrap_or(Params {
        endpoint: None,
        a2a_endpoint: None,
        owner: None,
        route_auth: None,
        allow_untrusted_endpoint: false,
    });
    let payload = req.params.get("payload").unwrap_or(&req.params);
    state
        .a2ui
        .validate_payload(payload)
        .map_err(|e| e.to_string())?;
    let envelopes = car_a2ui::envelopes_from_value(payload).map_err(|e| e.to_string())?;
    if envelopes.is_empty() {
        return Err("no A2UI envelopes found in payload".into());
    }
    let endpoint = params.endpoint.or(params.a2a_endpoint);
    let endpoint = trusted_route_endpoint(endpoint, params.allow_untrusted_endpoint);
    let owner = params
        .owner
        .or_else(|| car_a2ui::owner_from_value(payload))
        .map(|owner| match endpoint.clone() {
            Some(endpoint) => owner.with_endpoint(Some(endpoint)),
            None => owner,
        });

    let mut results = Vec::new();
    for envelope in envelopes {
        let value =
            apply_a2ui_envelope(state, envelope, owner.clone(), params.route_auth.clone()).await?;
        results.push(value);
    }
    Ok(serde_json::json!({ "applied": results }))
}

async fn apply_a2ui_envelope(
    state: &Arc<ServerState>,
    envelope: car_a2ui::A2uiEnvelope,
    owner: Option<car_a2ui::A2uiSurfaceOwner>,
    route_auth: Option<A2aRouteAuth>,
) -> Result<Value, String> {
    let result = state
        .a2ui
        .apply_with_owner(envelope, owner)
        .await
        .map_err(|e| e.to_string())?;
    update_a2ui_route_auth(state, &result, route_auth).await;
    let kind = if result.deleted {
        "a2ui.surface_deleted"
    } else {
        "a2ui.surface_updated"
    };
    let message = if result.deleted {
        format!("A2UI surface {} deleted", result.surface_id)
    } else {
        format!("A2UI surface {} updated", result.surface_id)
    };
    let payload = serde_json::to_value(&result).map_err(|e| e.to_string())?;
    state
        .host
        .record_event(kind, None, message, payload.clone())
        .await;
    // Push the envelope result to every WS subscriber as an
    // `a2ui.event` notification — Parslee-ai/car-releases#29. Late
    // joiners catch up via `a2ui/replay` (or `a2ui.surfaces`).
    broadcast_a2ui_event(state, kind, &payload).await;
    serde_json::to_value(result).map_err(|e| e.to_string())
}

async fn broadcast_a2ui_event(state: &Arc<ServerState>, kind: &str, result: &Value) {
    use futures::SinkExt;
    use tokio_tungstenite::tungstenite::Message;
    let subscribers: Vec<Arc<crate::session::WsChannel>> = state
        .a2ui_subscribers
        .lock()
        .await
        .values()
        .cloned()
        .collect();
    if subscribers.is_empty() {
        return;
    }
    let Ok(json) = serde_json::to_string(&serde_json::json!({
        "jsonrpc": "2.0",
        "method": "a2ui.event",
        "params": {
            "kind": kind,
            "result": result,
        },
    })) else {
        return;
    };
    for channel in subscribers {
        let _ = channel
            .write
            .lock()
            .await
            .send(Message::Text(json.clone().into()))
            .await;
    }
}

async fn update_a2ui_route_auth(
    state: &Arc<ServerState>,
    result: &car_a2ui::A2uiApplyResult,
    route_auth: Option<A2aRouteAuth>,
) {
    let mut auth = state.a2ui_route_auth.lock().await;
    if result.deleted {
        auth.remove(&result.surface_id);
        return;
    }

    let has_route_endpoint = result
        .surface
        .as_ref()
        .and_then(|surface| surface.owner.as_ref())
        .and_then(|owner| owner.endpoint.as_ref())
        .is_some();
    match (has_route_endpoint, route_auth) {
        (true, Some(route_auth)) => {
            auth.insert(result.surface_id.clone(), route_auth);
        }
        _ => {
            auth.remove(&result.surface_id);
        }
    }
}

fn handle_a2ui_capabilities(state: &Arc<ServerState>) -> Result<Value, String> {
    serde_json::to_value(state.a2ui.capabilities()).map_err(|e| e.to_string())
}

async fn handle_a2ui_reap(state: &Arc<ServerState>) -> Result<Value, String> {
    let removed = state.a2ui.reap_expired(chrono::Utc::now()).await;
    if !removed.is_empty() {
        let mut auth = state.a2ui_route_auth.lock().await;
        for surface_id in &removed {
            auth.remove(surface_id);
        }
    }
    Ok(serde_json::json!({ "removed": removed }))
}

async fn handle_a2ui_surfaces(state: &Arc<ServerState>) -> Result<Value, String> {
    serde_json::to_value(state.a2ui.list().await).map_err(|e| e.to_string())
}

async fn handle_a2ui_get(req: &JsonRpcMessage, state: &Arc<ServerState>) -> Result<Value, String> {
    let surface_id = req
        .params
        .get("surface_id")
        .or_else(|| req.params.get("surfaceId"))
        .and_then(Value::as_str)
        .ok_or_else(|| "`a2ui.get` requires surface_id".to_string())?;
    serde_json::to_value(state.a2ui.get(surface_id).await).map_err(|e| e.to_string())
}

/// `a2ui/subscribe` — opt this WS connection into `a2ui.event`
/// notifications. Subscribers receive every `apply_a2ui_envelope`
/// result for as long as they're connected; the cleanup hook in
/// `run_dispatch` removes them on disconnect. Closes
/// Parslee-ai/car-releases#29.
async fn handle_a2ui_subscribe(
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    state
        .a2ui_subscribers
        .lock()
        .await
        .insert(session.client_id.clone(), session.channel.clone());
    Ok(serde_json::json!({ "subscribed": true }))
}

/// `a2ui/unsubscribe` — opt out of `a2ui.event` notifications.
/// Idempotent: returns `{ subscribed: false }` regardless of prior
/// state.
async fn handle_a2ui_unsubscribe(
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    state
        .a2ui_subscribers
        .lock()
        .await
        .remove(&session.client_id);
    Ok(serde_json::json!({ "subscribed": false }))
}

/// `a2ui/replay` — fetch the current state of one surface. Intended
/// for late joiners and reconnect: a client calls `subscribe`, then
/// `replay` once per surface it's tracking, and from then on
/// notifications keep it in sync. Equivalent to `a2ui.get` on the
/// surface store; lives in the subscribe namespace for
/// discoverability.
async fn handle_a2ui_replay(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let surface_id = req
        .params
        .get("surface_id")
        .or_else(|| req.params.get("surfaceId"))
        .and_then(Value::as_str)
        .ok_or_else(|| "`a2ui/replay` requires surface_id".to_string())?;
    serde_json::to_value(state.a2ui.get(surface_id).await).map_err(|e| e.to_string())
}

async fn handle_a2ui_action(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let action: car_a2ui::ClientAction =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let owner = state.a2ui.owner(&action.surface_id).await;

    // Deliver the interaction to A2UI subscribers on the same channel that
    // carries surface updates — Parslee-ai/car-releases#58. The owning agent
    // listens for `a2ui.event` (that's how it gets `surface_updated`); before
    // this it saw nothing on click, so its surfaces were display-only.
    // Broadcast BEFORE the A2A route below: the local notification must not be
    // gated behind a (possibly slow) outbound A2A round-trip, and `route` is
    // deliberately excluded so an A2A endpoint URL/response isn't fanned out to
    // unrelated subscribers — it stays in the `host.event` record and the RPC
    // return for the privileged consumers that already see it.
    let action_result = serde_json::json!({
        "surfaceId": action.surface_id,
        "action": action,
        "owner": owner,
    });
    broadcast_a2ui_event(state, "a2ui.action", &action_result).await;

    let route = route_a2ui_action(state, &action, owner.clone()).await;
    let payload = serde_json::json!({
        "action": action,
        "owner": owner,
        "route": route,
    });
    let event = state
        .host
        .record_event(
            "a2ui.action",
            None,
            format!(
                "A2UI action {} from {}",
                action.name, action.source_component_id
            ),
            payload,
        )
        .await;
    Ok(serde_json::json!({
        "event": event,
        "route": route,
    }))
}

/// `a2ui.render_report` — renderer-emitted telemetry envelope
/// (Parslee-ai/car#180). Fire-and-forget; we record an event in
/// the host log (so dev tools and the conversation log see it) and
/// broadcast as `a2ui.event { kind: "a2ui.render_report", result }`
/// to every WS subscriber. The improvement agent reads the report
/// and decides whether to issue a follow-up `patchComponents`.
async fn handle_a2ui_render_report(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    // Parse into the typed struct to enforce the schema; we
    // re-serialize for the event/broadcast payload so downstream
    // consumers don't have to defensively re-validate.
    let report: car_a2ui::RenderReport =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let payload = serde_json::to_value(&report).map_err(|e| e.to_string())?;
    let kind = "a2ui.render_report";
    let message = format!("A2UI render report for surface {}", report.surface_id);
    let event = state
        .host
        .record_event(kind, None, message, payload.clone())
        .await;
    broadcast_a2ui_event(state, kind, &payload).await;

    // Hand the report to the in-process UI-improvement agent. The
    // agent is sync but cheap; we await the surface lookup before
    // calling it so the strategies see the same surface state the
    // renderer saw at report-emit time. Best-effort: surface lookup
    // misses (the surface might have been deleted between
    // report-emit and report-receive) are logged and skipped, never
    // surfaced as JSON-RPC errors — render_report is fire-and-forget.
    if let Some(surface) = state.a2ui.get(&report.surface_id).await {
        // Iteration budget — runaway-loop backstop. try_consume
        // claims the slot atomically; if the surface is already at
        // the cap, we short-circuit before consulting the agent.
        // The slot stays consumed only if the patch actually
        // applies — failure paths refund.
        if !state.ui_agent_budget.try_consume(&report.surface_id) {
            tracing::warn!(
                surface_id = %report.surface_id,
                count = state.ui_agent_budget.count(&report.surface_id),
                max = state.ui_agent_budget.max(),
                "ui-agent iteration budget exhausted; skipping agent invocation"
            );
            return Ok(serde_json::json!({ "event": event }));
        }
        // From here on, every non-applied branch must `refund` the
        // slot we just claimed. Only the successful-apply branch
        // keeps it consumed.
        match state.ui_agent.on_render_report(&report, &surface) {
            car_ui_agent::Decision::Patch {
                envelope,
                strategy_id,
                patch_hash,
                elapsed_ns,
            } => {
                // Runtime-side convergence monitor — neo's deferred
                // ask. The agent's no-double-patch guard catches
                // same-sequence repeats; this catches A→B→A across
                // sequences by tracking recent patch hashes per
                // surface. When a proposal would repeat one in the
                // window, drop it; the loop waits for the next
                // signature change.
                if !state
                    .ui_agent_oscillation
                    .check_and_record(&report.surface_id, patch_hash)
                {
                    tracing::warn!(
                        surface_id = %report.surface_id,
                        strategy = %strategy_id,
                        patch_hash,
                        "ui-agent oscillation detected; suppressing patch"
                    );
                    // Suppressed patch never applied → release the
                    // budget slot we claimed above.
                    state.ui_agent_budget.refund(&report.surface_id);
                    return Ok(serde_json::json!({ "event": event }));
                }
                let a2ui_envelope = car_a2ui::A2uiEnvelope {
                    patch_components: Some(envelope),
                    ..Default::default()
                };
                if let Err(e) = apply_a2ui_envelope(state, a2ui_envelope, None, None).await {
                    tracing::warn!(
                        surface_id = %report.surface_id,
                        strategy = %strategy_id,
                        patch_hash,
                        elapsed_ns,
                        error = %e,
                        "ui-agent patch apply failed",
                    );
                    // Apply failed → release the budget slot.
                    state.ui_agent_budget.refund(&report.surface_id);
                } else {
                    tracing::debug!(
                        surface_id = %report.surface_id,
                        strategy = %strategy_id,
                        patch_hash,
                        elapsed_ns,
                        iteration = state.ui_agent_budget.count(&report.surface_id),
                        "ui-agent patch applied",
                    );
                    // Memgine trace: one Conversation node per
                    // successful patch, tagged "ui-agent/<surface>".
                    // Spreading activation can surface these on
                    // future renders of related surfaces. Spawned
                    // off the render-report hot path — ingest walks
                    // the graph for spreading activation and can
                    // take real time; we don't want two surfaces
                    // churning patches to serialize through the
                    // memgine mutex behind the WS handler.
                    if let Some(memgine) = state.shared_memgine.clone() {
                        let speaker = format!("ui-agent/{}", report.surface_id);
                        let text = format!("strategy applied: {}", strategy_id);
                        tokio::spawn(async move {
                            let mut guard = memgine.lock().await;
                            guard.ingest_conversation(&speaker, &text, chrono::Utc::now());
                        });
                    }
                }
            }
            car_ui_agent::Decision::StableNoChange => {
                // No patch this round → release the slot.
                state.ui_agent_budget.refund(&report.surface_id);
            }
            car_ui_agent::Decision::HardStop { reason } => {
                state.ui_agent_budget.refund(&report.surface_id);
                // Renderer painted unknown components — contract
                // violation between server and renderer. Per
                // types.rs docs: "loud failure" + "MUST pause."
                // `error!` not `warn!` so it surfaces in production
                // logs at the right severity.
                tracing::error!(
                    surface_id = %report.surface_id,
                    reason = %reason,
                    "ui-agent hard-stopped improvement loop",
                );
            }
        }
    } else {
        tracing::debug!(
            surface_id = %report.surface_id,
            "ui-agent skipped — surface not found in store",
        );
    }

    Ok(serde_json::json!({ "event": event }))
}

async fn route_a2ui_action(
    state: &Arc<ServerState>,
    action: &car_a2ui::ClientAction,
    owner: Option<car_a2ui::A2uiSurfaceOwner>,
) -> Value {
    let Some(owner) = owner else {
        return serde_json::json!({ "delivered": false, "reason": "surface has no owner" });
    };
    if owner.kind != "a2a" {
        return serde_json::json!({ "delivered": false, "reason": "unsupported owner kind", "owner": owner });
    }
    let Some(endpoint) = owner.endpoint.clone() else {
        return serde_json::json!({
            "delivered": false,
            "reason": "surface owner has no endpoint",
            "owner": owner
        });
    };

    let message = car_a2a::Message {
        message_id: format!("a2ui-action-{}", uuid::Uuid::new_v4().simple()),
        role: car_a2a::MessageRole::User,
        parts: vec![car_a2a::Part::Data(car_a2a::types::DataPart {
            data: serde_json::json!({
                "a2uiAction": action,
            }),
            metadata: Default::default(),
        })],
        task_id: owner.task_id.clone(),
        context_id: owner.context_id.clone(),
        metadata: Default::default(),
    };

    let auth = state
        .a2ui_route_auth
        .lock()
        .await
        .get(&action.surface_id)
        .cloned()
        .map(client_auth_from_route_auth)
        .unwrap_or(car_a2a::ClientAuth::None);

    match car_a2a::A2aClient::new(endpoint.clone())
        .with_auth(auth)
        .send_message(message, false)
        .await
    {
        Ok(result) => serde_json::json!({
            "delivered": true,
            "owner": owner,
            "endpoint": endpoint,
            "result": result,
        }),
        Err(error) => serde_json::json!({
            "delivered": false,
            "owner": owner,
            "endpoint": endpoint,
            "error": error.to_string(),
        }),
    }
}

fn client_auth_from_route_auth(auth: A2aRouteAuth) -> car_a2a::ClientAuth {
    match auth {
        A2aRouteAuth::None => car_a2a::ClientAuth::None,
        A2aRouteAuth::Bearer { token } => car_a2a::ClientAuth::Bearer(token),
        A2aRouteAuth::Header { name, value } => car_a2a::ClientAuth::Header { name, value },
    }
}

fn trusted_route_endpoint(endpoint: Option<String>, allow_untrusted: bool) -> Option<String> {
    let endpoint = endpoint?;
    if allow_untrusted || is_loopback_http_endpoint(&endpoint) {
        Some(endpoint)
    } else {
        None
    }
}

fn is_loopback_http_endpoint(endpoint: &str) -> bool {
    endpoint == "http://localhost"
        || endpoint.starts_with("http://localhost:")
        || endpoint.starts_with("http://localhost/")
        || endpoint == "http://127.0.0.1"
        || endpoint.starts_with("http://127.0.0.1:")
        || endpoint.starts_with("http://127.0.0.1/")
        || endpoint == "http://[::1]"
        || endpoint.starts_with("http://[::1]:")
        || endpoint.starts_with("http://[::1]/")
}

async fn handle_host_register_agent(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let request: RegisterHostAgentRequest =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    serde_json::to_value(
        session
            .host
            .register_agent(&session.client_id, request)
            .await?,
    )
    .map_err(|e| e.to_string())
}

async fn handle_host_unregister_agent(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let agent_id = req
        .params
        .get("agent_id")
        .and_then(|v| v.as_str())
        .ok_or("missing agent_id")?;
    session
        .host
        .unregister_agent(&session.client_id, agent_id)
        .await?;
    Ok(serde_json::json!({"ok": true}))
}

async fn handle_host_set_status(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let request: SetHostAgentStatusRequest =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    serde_json::to_value(session.host.set_status(&session.client_id, request).await?)
        .map_err(|e| e.to_string())
}

async fn handle_host_notify(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let kind = req
        .params
        .get("kind")
        .and_then(|v| v.as_str())
        .unwrap_or("host.notification");
    let agent_id = req
        .params
        .get("agent_id")
        .and_then(|v| v.as_str())
        .map(str::to_string);
    let message = req
        .params
        .get("message")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let payload = req.params.get("payload").cloned().unwrap_or(Value::Null);
    serde_json::to_value(
        session
            .host
            .record_event(kind, agent_id, message, payload)
            .await,
    )
    .map_err(|e| e.to_string())
}

async fn handle_host_request_approval(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let request: CreateHostApprovalRequest =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    if let Some(agent_id) = &request.agent_id {
        // Best-effort. If the caller doesn't own this agent the
        // ACL added 2026-05 will refuse the status update — that
        // is the correct semantics; we still want the approval row
        // itself to land so the UI can render the request.
        let _ = session
            .host
            .set_status(
                &session.client_id,
                SetHostAgentStatusRequest {
                    agent_id: agent_id.clone(),
                    status: HostAgentStatus::WaitingForApproval,
                    current_task: None,
                    message: Some("Waiting for approval".to_string()),
                    payload: Value::Null,
                },
            )
            .await;
    }
    // `system_level: true` opts the approval out of per-session
    // ownership. The host-side ACL then allows any authenticated
    // session (typically CarHost or `car-host approve`) to resolve.
    // Agents requesting user approval should always set this — the
    // session-owned mode is only correct when the requesting session
    // is also the resolving session, which approval-via-UI never is.
    let owner_client_id = if request.system_level {
        None
    } else {
        Some(session.client_id.as_str())
    };
    serde_json::to_value(
        session
            .host
            .create_approval(owner_client_id, request)
            .await?,
    )
    .map_err(|e| e.to_string())
}

async fn handle_host_resolve_approval(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let request: ResolveHostApprovalRequest =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    serde_json::to_value(
        session
            .host
            .resolve_approval(&session.client_id, request)
            .await?,
    )
    .map_err(|e| e.to_string())
}

/// `session.auth` — present the per-launch token to unlock the
/// connection. When `state.auth_token` is unset, this method is a
/// no-op success (auth is disabled). When set, the supplied token
/// must equal it (constant-time comparison) — a successful auth
/// flips `session.authenticated` to `true` so subsequent methods
/// pass the gate. Wrong token returns an error AND leaves the
/// session unauthenticated; the dispatcher loop's gate then closes
/// the connection on the next non-auth method.
///
/// Closes Parslee-ai/car-releases#32.
async fn handle_session_auth(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    // C-4: optional `tenant_id` binds the connection to one tenant
    // namespace. Parsed + validated up front, bound only on auth
    // success. Once bound, tenant-scoped handlers use this identity
    // and reject a conflicting per-request `tenant_id`.
    let tenant_binding = match req.params.get("tenant_id").and_then(Value::as_str) {
        Some(t) if !t.is_empty() => {
            validate_tenant_id(t)?;
            Some(t.to_string())
        }
        _ => None,
    };

    // #254: optional `host_token` elevates the connection to the
    // host-management role. It is a *distinct* credential from the
    // daemon `token` — only readable from the `0600` host-token file,
    // never served over `GET /auth-token` — so a generic authenticated
    // client (or another local user who scraped the auth token via the
    // HTTP endpoint) cannot self-elevate to host and read other agents'
    // run traces. Validated first, before the `token` requirement,
    // because presenting a valid host token both authenticates and
    // elevates the session (a host client sends only `host_token`).
    if let Some(host_supplied) = req.params.get("host_token").and_then(Value::as_str) {
        let expected = state.host_token.get().ok_or_else(|| {
            "host auth unavailable: this daemon has no host token (started with --no-auth?)"
                .to_string()
        })?;
        if !constant_time_eq(host_supplied.as_bytes(), expected.as_bytes()) {
            return Err("auth failed: host token mismatch".to_string());
        }
        session
            .authenticated
            .store(true, std::sync::atomic::Ordering::Release);
        session
            .is_host
            .store(true, std::sync::atomic::Ordering::Release);
        *session.tenant.lock().await = tenant_binding;
        return Ok(serde_json::json!({
            "ok": true,
            "auth_enabled": true,
            "role": "host",
        }));
    }

    let supplied = req
        .params
        .get("token")
        .and_then(Value::as_str)
        .ok_or_else(|| "session.auth requires { token: string }".to_string())?;
    // #169: optional `agent_id` binds the WS connection to a
    // supervised lifecycle agent. When present, the supplied token
    // must equal the per-agent token the supervisor minted at upsert
    // (NOT the daemon-wide auth token). When absent, fall back to
    // the daemon-wide token — preserves the legacy unbound-token
    // path for browser/host/CLI clients.
    let agent_id = req
        .params
        .get("agent_id")
        .and_then(Value::as_str)
        .map(str::to_string);

    if let Some(id) = agent_id {
        let supervisor = state.supervisor()?;
        if !supervisor.validate_agent_token(&id, supplied).await {
            return Err(format!(
                "auth failed: agent_id `{id}` is not supervised, or token mismatch"
            ));
        }
        // Single-claim: only one connection at a time per
        // agent_id. A second claim is rejected so the daemon-side
        // per-agent state stays unambiguous.
        {
            let mut attached = state.attached_agents.lock().await;
            if let Some(prior) = attached.get(&id) {
                if prior != &session.client_id {
                    return Err(format!(
                        "auth failed: agent_id `{id}` is already attached on \
                         another connection (client_id={prior})"
                    ));
                }
            }
            attached.insert(id.clone(), session.client_id.clone());
        }
        // #170: attach the daemon-owned persistent memgine for
        // this agent. Lazy-loaded on first connection per id from
        // `~/.car/memory/agents/<id>.jsonl`; retained across
        // disconnect so the next session sees the same state.
        let agent_eng = get_or_load_agent_memgine(state, &id).await?;
        *session.bound_memgine.lock().await = Some(agent_eng);
        *session.agent_id.lock().await = Some(id.clone());
        session
            .authenticated
            .store(true, std::sync::atomic::Ordering::Release);
        *session.tenant.lock().await = tenant_binding;
        return Ok(serde_json::json!({
            "ok": true,
            "auth_enabled": true,
            "agent_id": id,
        }));
    }

    let expected = match state.auth_token.get() {
        Some(t) => t,
        None => {
            // Auth disabled — accept any token politely so callers
            // that always include a session.auth handshake (e.g. the
            // FFI proxy) don't fail when the daemon happens to be
            // unauthed. Mark the session authenticated anyway so the
            // gate is a no-op below.
            session
                .authenticated
                .store(true, std::sync::atomic::Ordering::Release);
            *session.tenant.lock().await = tenant_binding;
            return Ok(serde_json::json!({ "ok": true, "auth_enabled": false }));
        }
    };
    if !constant_time_eq(supplied.as_bytes(), expected.as_bytes()) {
        return Err("auth failed: token mismatch".to_string());
    }
    session
        .authenticated
        .store(true, std::sync::atomic::Ordering::Release);
    *session.tenant.lock().await = tenant_binding;
    Ok(serde_json::json!({
        "ok": true,
        "auth_enabled": true,
        "parslee": state.parslee_session.get().map(|session| session.identity.clone()),
    }))
}

/// Length-checked constant-time byte comparison. Returns false when
/// lengths differ (so length itself is the only timing leak — fine
/// for our 43-char fixed-length tokens).
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff: u8 = 0;
    for (x, y) in a.iter().zip(b.iter()) {
        diff |= x ^ y;
    }
    diff == 0
}

/// Block dispatch of `method` until the user resolves the approval
/// raised on [`HostState`].
///
/// Called from the dispatcher loop only when
/// [`crate::session::ApprovalGate::requires_approval`] returns true.
/// `Ok(())` means the user picked "approve"; `Err(reason)` is sent
/// to the caller as JSON-RPC error code `-32003` with the supplied
/// reason. On timeout, the approval row stays in `Pending` so the
/// UI keeps a record of the unanswered request.
async fn gate_high_risk_method(
    method: &str,
    params: &Value,
    state: &Arc<ServerState>,
) -> Result<(), String> {
    let timeout = state.approval_gate.timeout;
    let req = CreateHostApprovalRequest {
        agent_id: None,
        action: format!("ws.method:{method}"),
        details: serde_json::json!({
            "method": method,
            // Truncate params for the UI — full payload is recoverable
            // via the request-time host event log if needed. The cap
            // keeps a malicious caller from drowning the UI in JSON.
            "params_preview": preview_params(params, 2_000),
        }),
        options: vec!["approve".to_string(), "deny".to_string()],
        // The high-risk-method gate is already system-level (it
        // passes None as the owner via request_and_wait_approval's
        // internal call). This field is informational here.
        system_level: true,
    };
    match state
        .host
        .request_and_wait_approval(req, "approve", timeout)
        .await
    {
        Ok(crate::host::ApprovalOutcome::Approved) => Ok(()),
        Ok(crate::host::ApprovalOutcome::Denied) => Err(format!(
            "{method} denied by user (approval gate, audit 2026-05). \
             To call this method without an interactive prompt, start \
             car-server with --no-approvals on a trusted machine."
        )),
        Ok(crate::host::ApprovalOutcome::TimedOut) => Err(format!(
            "{method} approval timed out after {}s with no resolution. \
             The approval is still visible in `host.approvals` for \
             forensics; resubmit the request to retry.",
            timeout.as_secs()
        )),
        Err(e) => Err(format!("approval gate error: {e}")),
    }
}

fn preview_params(value: &Value, max_chars: usize) -> Value {
    let s = value.to_string();
    if s.len() <= max_chars {
        value.clone()
    } else {
        Value::String(format!("{}… (truncated)", &s[..max_chars]))
    }
}

async fn handle_session_init(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let init: SessionInitRequest =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;

    for tool in &init.tools {
        register_from_definition(&session.runtime, tool).await;
    }

    let mut policy_count = 0;
    {
        let mut policies = session.runtime.policies.write().await;
        for policy_def in &init.policies {
            if let Some(check) = build_policy_check(policy_def) {
                policies.register(&policy_def.name, check, "");
                policy_count += 1;
            }
        }
    }

    serde_json::to_value(SessionInitResponse {
        session_id: session.client_id.clone(),
        tools_registered: init.tools.len(),
        policies_registered: policy_count,
    })
    .map_err(|e| e.to_string())
}

/// `session.bindSubstrate { connector: "<slug>" }` — bind THIS session's
/// runtime to the execution substrate of an already-connected MCP
/// connector (`docs/execution-substrate.md` phase 3). After binding, the
/// session's commodity built-ins (`read_file`/`write_file`/`edit_file`/
/// `list_dir`/`find_files`/`grep_files`) execute on the connector's
/// environment instead of the WS client/host, so they co-locate with the
/// session's `mcp_{slug}_*` tools.
///
/// Opt-in and per-session: a session that never calls this keeps the
/// historic host/client composition. Returns
/// `{ "bound": true, "substrate": "<name>" }` on success.
async fn handle_session_bind_substrate(
    req: &JsonRpcMessage,
    session: &Arc<crate::session::ClientSession>,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let connector = req
        .params
        .get("connector")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `connector` (MCP connector slug)".to_string())?;

    let name = state
        .bind_substrate_to_connector(session, connector)
        .await?;
    Ok(serde_json::json!({ "bound": true, "substrate": name }))
}

/// `session.bindSandbox { working_dir, image?, network?, memory?, cpus?,
/// pids_limit?, persistent?, command_timeout_secs? }` — bind this session's
/// runtime to a Docker-sandboxed execution environment (D1). The session's
/// commodity built-ins (shell / read_file / write_file / list_dir /
/// grep_files) then run INSIDE a hardened container mounted on
/// `working_dir` (default-secure: no network unless `network` is set,
/// memory/pids/cpu caps, dropped capabilities — D2/D3).
///
/// Preflights Docker + the image FIRST and returns the preflight's
/// actionable error when the host can't run containers (no daemon, image
/// unpullable) — an explicit, typed refusal, never a silent fallback to
/// unsandboxed local execution (D3: substituting the host for the sandbox
/// behind the caller's back would be a security downgrade).
async fn handle_session_bind_sandbox(
    req: &JsonRpcMessage,
    session: &Arc<crate::session::ClientSession>,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let working_dir = req
        .params
        .get("working_dir")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `working_dir`".to_string())?;
    let wd = std::path::Path::new(working_dir);
    if !wd.is_dir() {
        return Err(format!("working_dir '{working_dir}' is not a directory"));
    }

    let mut config = car_sandbox::SandboxConfig {
        working_dir: wd.to_path_buf(),
        ..Default::default()
    };
    if let Some(image) = req.params.get("image").and_then(|v| v.as_str()) {
        config.image = image.to_string();
    }
    // Egress stays OFF unless explicitly requested (D2).
    if let Some(net) = req.params.get("network").and_then(|v| v.as_str()) {
        config.network = Some(net.to_string());
    }
    if let Some(mem) = req.params.get("memory").and_then(|v| v.as_str()) {
        config.memory = Some(mem.to_string());
    }
    if let Some(cpus) = req.params.get("cpus").and_then(|v| v.as_str()) {
        config.cpus = Some(cpus.to_string());
    }
    if let Some(pids) = req.params.get("pids_limit").and_then(|v| v.as_u64()) {
        config.pids_limit = Some(pids);
    }
    // NOTE: no `persistent` param — SandboxConfig::persistent is not yet
    // wired in the executor (the container is always cached per executor),
    // and advertising a no-op knob is worse than not offering it (review Q1).
    if let Some(t) = req
        .params
        .get("command_timeout_secs")
        .and_then(|v| v.as_u64())
    {
        if t == 0 {
            return Err("command_timeout_secs must be >= 1".to_string());
        }
        config.command_timeout_secs = t;
    }

    // D3: preflight before binding — a host without Docker gets the
    // preflight's own actionable message (install/start Docker, pull the
    // image), not a broken half-bound session.
    let pf = car_sandbox::preflight(&config.image).await;
    if !pf.is_ok() {
        return Err(format!("sandbox preflight failed: {}", pf.message()));
    }

    let executor = std::sync::Arc::new(car_sandbox::SandboxExecutor::new(config));
    let substrate: std::sync::Arc<dyn car_engine::Substrate> = executor;
    let name = substrate.name().to_string();

    // Same three-step swap as bind_substrate_to_connector (see its note on
    // why the non-atomic swap is benign: a WS connection handles its
    // JSON-RPC sequentially and bind is a control call issued before tool
    // work): bind the substrate, re-register the substrate-backed
    // built-ins, then shadow the substrate-owned tool names so they fall
    // through to the sandbox while everything else keeps the WS callback
    // path.
    session.runtime.set_substrate(substrate).await;
    session.runtime.register_agent_basics().await;
    // Compose EXACTLY like bind_substrate_to_connector: connector
    // (mcp_{slug}_*) routes first, then the WS client callback, with the
    // substrate-owned built-in names shadowed to the sandbox. Wrapping the
    // bare WS executor here severed every connector tool on the session —
    // still registered and advertised to the model, but dispatched to a WS
    // client that has never heard of them (review C1).
    let ws_executor = std::sync::Arc::new(crate::session::WsToolExecutor {
        channel: session.channel.clone(),
    });
    let composed: std::sync::Arc<dyn car_engine::ToolExecutor> =
        std::sync::Arc::new(state.mcp_executor.share_with_fallback(ws_executor));
    let shadowed: std::sync::Arc<dyn car_engine::ToolExecutor> =
        std::sync::Arc::new(crate::session::SubstrateShadowExecutor::new(composed));
    session.runtime.set_executor(shadowed).await;

    Ok(serde_json::json!({ "bound": true, "substrate": name }))
}

fn build_policy_check(def: &PolicyDefinition) -> Option<car_policy::PolicyCheck> {
    match def.rule.as_str() {
        "deny_tool" => {
            let target = def.target.clone();
            Some(Box::new(
                move |action: &car_ir::Action, _: &car_state::StateStore| {
                    if action.tool.as_deref() == Some(&target) {
                        Some(format!("tool '{}' denied", target))
                    } else {
                        None
                    }
                },
            ))
        }
        // Deny every tool from a remote MCP connector by slug. Connector
        // tools are named `mcp_{slug}_{tool}` (car-connectors), so a
        // single rule governs the whole connector — the policy-level
        // counterpart to per-tool enablement.
        "deny_connector" => {
            let slug = def.target.clone();
            let prefix = format!("mcp_{slug}_");
            Some(Box::new(
                move |action: &car_ir::Action, _: &car_state::StateStore| match action
                    .tool
                    .as_deref()
                {
                    Some(tool) if tool.starts_with(&prefix) => {
                        Some(format!("connector '{}' denied", slug))
                    }
                    _ => None,
                },
            ))
        }
        "require_state" => {
            let key = def.key.clone();
            let value = def.value.clone();
            Some(Box::new(
                move |_: &car_ir::Action, state: &car_state::StateStore| {
                    if state.get(&key).as_ref() != Some(&value) {
                        Some(format!("state['{}'] must be {:?}", key, value))
                    } else {
                        None
                    }
                },
            ))
        }
        "deny_tool_param" => {
            let target = def.target.clone();
            let param = def.key.clone();
            let pattern = def.pattern.clone();
            Some(Box::new(
                move |action: &car_ir::Action, _: &car_state::StateStore| {
                    if action.tool.as_deref() != Some(&target) {
                        return None;
                    }
                    if let Some(val) = action.parameters.get(&param) {
                        let s = val.as_str().unwrap_or(&val.to_string()).to_string();
                        if s.contains(&pattern) {
                            return Some(format!("param '{}' matches '{}'", param, pattern));
                        }
                    }
                    None
                },
            ))
        }
        _ => None,
    }
}

async fn handle_tools_register(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let tools: Vec<ToolDefinition> =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    for tool in &tools {
        register_from_definition(&session.runtime, tool).await;
    }
    Ok(Value::from(tools.len()))
}

/// Bridge a wire-protocol `ToolDefinition` to the engine's
/// schema-aware registration. Carries the full ToolSchema shape
/// (description, parameters, returns, idempotency, caching, rate
/// limit) through to the validator. An empty `parameters` object is
/// the legacy schemaless registration — the validator no-ops for
/// those, so pre-v0.5.x callers see no change.
async fn register_from_definition(runtime: &car_engine::Runtime, def: &ToolDefinition) {
    runtime
        .register_tool_schema(car_ir::ToolSchema {
            name: def.name.clone(),
            description: def.description.clone(),
            parameters: def.parameters.clone(),
            returns: def.returns.clone(),
            idempotent: def.idempotent,
            cache_ttl_secs: def.cache_ttl_secs,
            rate_limit: def.rate_limit.as_ref().map(|rl| car_ir::ToolRateLimit {
                max_calls: rl.max_calls,
                interval_secs: rl.interval_secs,
            }),
        })
        .await;
}

// ---- Detached tool dispatch (streaming/long-running, EPIC C / C2) ----
//
// A ToolCall action with `invocation_mode: "streaming" | "long_running"`
// is *started*, not awaited: its output is `{tool_handle, status:
// "running"}` and the DAG proceeds. These methods drive the handle
// against the session runtime's `ToolHandleRegistry`.

/// `tools.poll { handle }` — drain the chunks buffered since the last
/// poll plus the invocation's current status (and, once terminal, its
/// final result/error). Returns `null` for an unknown or already
/// fully-consumed handle — absence, not an error (a terminal handle is
/// removed after its final state has been observed once with an empty
/// buffer, per the `car_engine::tool_handles` contract).
async fn handle_tools_poll(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let handle = req
        .params
        .get("handle")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `handle`".to_string())?;
    match session.runtime.tool_poll(handle).await {
        Some(res) => serde_json::to_value(&res).map_err(|e| e.to_string()),
        None => Ok(Value::Null),
    }
}

/// `tools.cancel { handle }` — request cooperative cancellation of a
/// detached invocation. Returns `{ cancelled: bool }` — `false` for an
/// unknown handle. Distinct from the server → client `tools.cancel`
/// *notification* (car#264, emitted when a `tools.execute` callback is
/// reaped): same method name, opposite direction.
async fn handle_tools_cancel(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let handle = req
        .params
        .get("handle")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `handle`".to_string())?;
    let cancelled = session.runtime.tool_cancel(handle).await;
    Ok(serde_json::json!({ "cancelled": cancelled }))
}

/// `tools.stream.subscribe {}` — forward every [`car_ir::ToolStreamEvent`]
/// from this session runtime's fanout to the connection as a
/// `tools.stream.event` JSON-RPC notification `{ handle, chunk }`.
/// Idempotent per connection (a second subscribe is a no-op — one
/// forwarder task per session). The forwarder holds only the broadcast
/// receiver + the WS channel: it exits when the socket is gone (write
/// failure/timeout, same bounded-write policy as the run-trace drain
/// task) or the broadcast closes; on lag it skips the missed events and
/// keeps forwarding — missed chunks remain drainable via `tools.poll`,
/// which reads the buffer, not the broadcast.
async fn handle_tools_stream_subscribe(
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    use std::sync::atomic::Ordering;
    if session
        .tool_stream_subscribed
        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
        .is_err()
    {
        // Already forwarding to this connection.
        return Ok(serde_json::json!({ "subscribed": true }));
    }
    let mut rx = session.runtime.subscribe_tool_events();
    let channel = session.channel.clone();
    // Q2 (linus review): the forwarder can exit on a 10s write TIMEOUT
    // while the connection later recovers — the flag must reset when
    // the task exits so a re-subscribe spawns a fresh forwarder instead
    // of no-op'ing while events silently never flow again.
    let subscribed = session.tool_stream_subscribed.clone();
    tokio::spawn(async move {
        use futures::SinkExt;
        struct ResetOnExit(std::sync::Arc<std::sync::atomic::AtomicBool>);
        impl Drop for ResetOnExit {
            fn drop(&mut self) {
                self.0.store(false, std::sync::atomic::Ordering::SeqCst);
            }
        }
        let _reset_on_exit = ResetOnExit(subscribed);
        loop {
            match rx.recv().await {
                Ok(ev) => {
                    let Ok(json) = serde_json::to_string(&serde_json::json!({
                        "jsonrpc": "2.0",
                        "method": "tools.stream.event",
                        "params": { "handle": ev.handle.id, "chunk": ev.chunk },
                    })) else {
                        continue;
                    };
                    // Bounded write: a wedged socket must not park this
                    // task on a full TCP buffer forever. Any write
                    // failure/timeout means the connection is unusable —
                    // stop forwarding (chunks stay pollable).
                    let mut guard = channel.write.lock().await;
                    let send = tokio::time::timeout(
                        std::time::Duration::from_secs(10),
                        guard.send(Message::Text(json.into())),
                    )
                    .await;
                    drop(guard);
                    match send {
                        Ok(Ok(())) => {}
                        _ => break,
                    }
                }
                // Lagged subscriber: the bounded broadcast dropped its
                // oldest events. Not fatal — resume from the current
                // position; the gap is recoverable via tools.poll.
                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
            }
        }
    });
    Ok(serde_json::json!({ "subscribed": true }))
}

// ---- Remote MCP connectors (CAR as MCP client) ----------------------
//
// All connector state is process-wide on `ServerState` (shared
// McpToolExecutor + ConnectorManager), so these handlers take `&state`
// rather than the per-session runtime. Each ensures the persisted
// connectors are loaded before acting (idempotent after the first call
// / boot-time load).

/// `connectors.add { name, url, headers? }` — register a remote MCP
/// server, connect, and discover its tools. `headers` is an optional
/// map of secret auth-header name → value, stored in the keychain (not
/// the manifest). No tools are enabled by the add itself; the response
/// reports the discovered count.
async fn handle_connectors_add(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    state.ensure_connectors_loaded().await;
    let name = req
        .params
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `name`".to_string())?;
    let url = req
        .params
        .get("url")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `url`".to_string())?;
    let headers: Vec<(String, String)> = req
        .params
        .get("headers")
        .and_then(|v| v.as_object())
        .map(|m| {
            m.iter()
                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
                .collect()
        })
        .unwrap_or_default();

    let status = state
        .connectors()
        .add(name, url, headers)
        .await
        .map_err(|e| e.to_string())?;
    serde_json::to_value(status).map_err(|e| e.to_string())
}

/// `connectors.add_stdio { name, command, args?, env? }` — register a
/// local stdio MCP server (subprocess transport), connect, and discover
/// its tools. No tools are enabled by the add itself.
async fn handle_connectors_add_stdio(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    state.ensure_connectors_loaded().await;
    let name = req
        .params
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `name`".to_string())?;
    let command = req
        .params
        .get("command")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `command`".to_string())?;
    let args: Vec<String> = req
        .params
        .get("args")
        .and_then(|v| v.as_array())
        .map(|a| {
            a.iter()
                .filter_map(|v| v.as_str().map(str::to_string))
                .collect()
        })
        .unwrap_or_default();
    let env: std::collections::BTreeMap<String, String> = req
        .params
        .get("env")
        .and_then(|v| v.as_object())
        .map(|m| {
            m.iter()
                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
                .collect()
        })
        .unwrap_or_default();

    let status = state
        .connectors()
        .add_stdio(name, command, args, env)
        .await
        .map_err(|e| e.to_string())?;
    serde_json::to_value(status).map_err(|e| e.to_string())
}

/// `connectors.authenticate { name, url, redirect_uri }` — begin an
/// OAuth 2.1 flow for a remote MCP server. Runs discovery + dynamic
/// client registration + PKCE, returns `{ authorize_url, state }`. The
/// GUI opens `authorize_url` (with its own `redirect_uri` callback),
/// captures the `code`, and calls `connectors.complete_authentication`.
async fn handle_connectors_authenticate(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    state.ensure_connectors_loaded().await;
    let name = req
        .params
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `name`".to_string())?;
    let url = req
        .params
        .get("url")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `url`".to_string())?;
    let redirect_uri = req
        .params
        .get("redirect_uri")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `redirect_uri`".to_string())?;

    let (authorize_url, oauth_state) = state
        .connectors()
        .authenticate(name, url, redirect_uri)
        .await
        .map_err(|e| e.to_string())?;
    Ok(serde_json::json!({ "authorize_url": authorize_url, "state": oauth_state }))
}

/// `connectors.complete_authentication { state, code }` — finish the
/// OAuth flow started by `connectors.authenticate`: exchange the code,
/// store tokens in the keychain, connect, and discover tools.
async fn handle_connectors_complete_authentication(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    state.ensure_connectors_loaded().await;
    let oauth_state = req
        .params
        .get("state")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `state`".to_string())?;
    let code = req
        .params
        .get("code")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `code`".to_string())?;

    let status = state
        .connectors()
        .complete_authentication(oauth_state, code)
        .await
        .map_err(|e| e.to_string())?;
    serde_json::to_value(status).map_err(|e| e.to_string())
}

/// `connectors.list` — all configured connectors with status.
async fn handle_connectors_list(state: &Arc<ServerState>) -> Result<Value, String> {
    state.ensure_connectors_loaded().await;
    let list = state.connectors().list().await;
    serde_json::to_value(list).map_err(|e| e.to_string())
}

/// `connectors.tools { slug }` — discovered tools for a connector,
/// flagged by whether each is enabled.
async fn handle_connectors_tools(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    state.ensure_connectors_loaded().await;
    let slug = req
        .params
        .get("slug")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `slug`".to_string())?;
    let tools = state
        .connectors()
        .tools(slug)
        .await
        .map_err(|e| e.to_string())?;
    serde_json::to_value(tools).map_err(|e| e.to_string())
}

/// `connectors.enable_tools { slug, tools: [..] }` — enable a set of
/// (bare, server-side) tool names. Routes them, registers their
/// schemas into every open session's runtime, and persists the choice.
async fn handle_connectors_enable_tools(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    state.ensure_connectors_loaded().await;
    let slug = req
        .params
        .get("slug")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `slug`".to_string())?;
    let tools: Vec<String> = req
        .params
        .get("tools")
        .and_then(|v| v.as_array())
        .map(|a| {
            a.iter()
                .filter_map(|v| v.as_str().map(str::to_string))
                .collect()
        })
        .ok_or_else(|| "missing `tools` array".to_string())?;

    let entries = state
        .connectors()
        .enable_tools(slug, &tools)
        .await
        .map_err(|e| e.to_string())?;
    let enabled = entries.len();
    state.register_connector_entries(&entries).await;
    Ok(serde_json::json!({ "enabled": enabled }))
}

/// `connectors.refresh { slug }` — re-run `tools/list` and re-register
/// any still-enabled tools.
async fn handle_connectors_refresh(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    state.ensure_connectors_loaded().await;
    let slug = req
        .params
        .get("slug")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `slug`".to_string())?;
    let tools = state
        .connectors()
        .refresh(slug)
        .await
        .map_err(|e| e.to_string())?;
    // Re-seed enabled entries into live sessions in case the refresh
    // re-discovered a previously-enabled tool.
    let entries = state.connectors().enabled_tool_entries().await;
    state.register_connector_entries(&entries).await;
    serde_json::to_value(tools).map_err(|e| e.to_string())
}

/// `connectors.remove { slug }` — disconnect, drop routes, delete
/// keychain secrets, and remove from the manifest.
async fn handle_connectors_remove(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    state.ensure_connectors_loaded().await;
    let slug = req
        .params
        .get("slug")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `slug`".to_string())?;
    let canonicals = state
        .connectors()
        .remove(slug)
        .await
        .map_err(|e| e.to_string())?;
    state.unregister_connector_tools(&canonicals).await;
    Ok(serde_json::json!({ "removed": slug }))
}

/// `connectors.disable_tools { slug, tools: [..] }` — disable a set of
/// (bare, server-side) tool names: drop their routes and unregister
/// their schemas from every open session's runtime.
async fn handle_connectors_disable_tools(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    state.ensure_connectors_loaded().await;
    let slug = req
        .params
        .get("slug")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "missing `slug`".to_string())?;
    let tools: Vec<String> = req
        .params
        .get("tools")
        .and_then(|v| v.as_array())
        .map(|a| {
            a.iter()
                .filter_map(|v| v.as_str().map(str::to_string))
                .collect()
        })
        .ok_or_else(|| "missing `tools` array".to_string())?;

    let canonicals = state
        .connectors()
        .disable_tools(slug, &tools)
        .await
        .map_err(|e| e.to_string())?;
    let disabled = canonicals.len();
    state.unregister_connector_tools(&canonicals).await;
    Ok(serde_json::json!({ "disabled": disabled }))
}

async fn handle_proposal_submit(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let submit: ProposalSubmitRequest =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    // `session_id` is sibling to `proposal` in the params object —
    // not part of `ProposalSubmitRequest` (kept proto-compatible). When
    // present, executes the proposal under the named session so any
    // session-scoped policies layer on top of global ones.
    // See docs/proposals/per-session-policy-scoping.md.
    let session_id = req
        .params
        .get("session_id")
        .and_then(|v| v.as_str())
        .map(str::to_string);

    // `scope` is the optional per-execution caller / tenant surface
    // added in Parslee-ai/car#187 phase 3. Shape mirrors
    // `car_engine::RuntimeScope` ({ callerId, tenantId, claims });
    // serde's camelCase rename keeps the wire form consistent with
    // the rest of the JSON-RPC surface. When present, the proposal
    // routes through `execute_scoped*` so the runtime records the
    // identity on the event log and state R/W ops apply the tenant
    // prefix (#187 phase 3-B).
    let scope: Option<car_engine::RuntimeScope> = match req.params.get("scope") {
        Some(v) if !v.is_null() => {
            Some(serde_json::from_value(v.clone()).map_err(|e| format!("invalid scope: {e}"))?)
        }
        _ => None,
    };

    let result = match (session_id, scope) {
        // session_id + scope: no single combined entry point on
        // Runtime today. Scope takes precedence because it's the
        // tenant-isolation surface; per-session policies layer in
        // a follow-up when there's a real caller asking for both.
        (Some(_sid), Some(s)) => session.runtime.execute_scoped(&submit.proposal, &s).await,
        (Some(sid), None) => {
            session
                .runtime
                .execute_with_session(&submit.proposal, &sid)
                .await
        }
        (None, Some(s)) => session.runtime.execute_scoped(&submit.proposal, &s).await,
        (None, None) => session.runtime.execute(&submit.proposal).await,
    };

    // Agent run tracing (U2): when this connection has a current run open
    // (set by `runs.start` before its ack — KTD3), record one per-turn
    // trace record per action. The recorder joins the submitted proposal's
    // actions to `result.results` by `action_id` (ActionResult carries no
    // tool/params) and classifies Bulldozer's `drive_cli`/`check_outcome`
    // shapes generically. The turn stream lives on the run's in-memory
    // buffer for U3 (disk) / U4 (broadcast) to consume; recording never
    // affects the proposal response.
    // Snapshot the current run id and release the per-session lock before
    // touching the process-wide `runs` registry — keeps the two locks
    // disjoint (no `current_run_id` held across a `runs` acquisition).
    let current_run = session.current_run_id.lock().await.clone();
    if let Some(run_id) = current_run {
        let start_index = state.run_turn_count(&run_id).await;
        let records =
            crate::run_trace::record_turns(&submit.proposal, &result.results, start_index);
        if !records.is_empty() {
            // The proposal recorder flows through the SAME ceiling-enforcing
            // path as the WS handler (ADV-1): a runaway main-agent loop is
            // backstopped here too. The outcome is intentionally not
            // surfaced on the `proposal.submit` response — tracing never
            // affects the proposal result — but a ceiling refusal is logged
            // so a runaway is observable. The cap (2000 turns) is far above
            // any proposal-driven run a test seeds, so existing run-trace
            // tests are unaffected.
            match state.record_run_turns(&run_id, records).await {
                crate::session::RecordRunTurnsOutcome::RefusedCeiling => {
                    tracing::warn!(
                        run_id = %run_id,
                        "proposal-recorder: run hit the turn ceiling; trace append refused (runaway backstop)"
                    );
                }
                crate::session::RecordRunTurnsOutcome::Appended { .. }
                | crate::session::RecordRunTurnsOutcome::UnknownOrTerminal => {}
            }
        }
    }

    serde_json::to_value(result).map_err(|e| e.to_string())
}

async fn handle_session_policy_open(
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let id = session.runtime.open_session().await;
    Ok(serde_json::json!({ "session_id": id }))
}

async fn handle_session_policy_close(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let sid = req
        .params
        .get("session_id")
        .and_then(|v| v.as_str())
        .ok_or("missing 'session_id'")?;
    let closed = session.runtime.close_session(sid).await;
    Ok(serde_json::json!({ "closed": closed }))
}

/// `policy.register` — register one policy against this WebSocket
/// session's runtime. Mirrors the `PolicyDefinition` shape used by
/// `session.init`. When `session_id` is present, the policy is scoped
/// to the named in-runtime session opened via `session.policy.open`;
/// otherwise it is global.
async fn handle_policy_register(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let def: PolicyDefinition = serde_json::from_value(req.params.clone())
        .map_err(|e| format!("invalid policy params: {e}"))?;
    let session_id = req
        .params
        .get("session_id")
        .and_then(|v| v.as_str())
        .map(str::to_string);
    let check = build_policy_check(&def)
        .ok_or_else(|| format!("unsupported policy rule '{}'", def.rule))?;
    match session_id {
        Some(sid) => session
            .runtime
            .register_policy_in_session(&sid, &def.name, check, "")
            .await
            .map(|_| serde_json::json!({ "registered": def.name, "scope": { "session_id": sid } })),
        None => {
            let mut policies = session.runtime.policies.write().await;
            policies.register(&def.name, check, "");
            Ok(serde_json::json!({ "registered": def.name, "scope": "global" }))
        }
    }
}

async fn handle_verify(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let vr: VerifyRequest =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    // Verify against the full registered schemas so tool_call
    // parameters are checked for type mismatches + missing required
    // fields, not just tool existence (register_tool_schema's
    // documented contract; car-releases#56). The read guard is held
    // across the synchronous verify call.
    let tools_guard = session.runtime.tools.read().await;
    let result = car_verify::verify_with_schemas(
        &vr.proposal,
        Some(&vr.initial_state),
        Some(&tools_guard),
        30,
    );
    drop(tools_guard);
    let evidence = serde_json::to_value(&result.evidence).unwrap_or(Value::Null);
    serde_json::to_value(VerifyResponse {
        valid: result.valid,
        issues: result
            .issues
            .iter()
            .map(|i| VerifyIssueProto {
                action_id: i.action_id.clone(),
                severity: i.severity.clone(),
                message: i.message.clone(),
            })
            .collect(),
        simulated_state: result.simulated_state,
        execution_levels: result.execution_levels,
        conflicts: result.conflicts,
        evidence,
    })
    .map_err(|e| e.to_string())
}

// --- sync.* / lease.* : multi-device sync + execution lease (B6) ---
//
// Thin param-parsers over the daemon-held `SyncSubsystem` (one per daemon = one
// device). Each locks the subsystem's tokio mutex and calls a `&mut self`
// method; the sync mechanics, fold, fence, and lease coordination all live in
// `crate::sync`. See `docs/proposals/multi-device-sync.md` §B6.

async fn handle_sync_status(state: &ServerState) -> Result<Value, String> {
    let sub = state.sync_subsystem()?;
    let mut s = sub.lock().await;
    s.status()
}

async fn handle_sync_append(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let surface_str = req
        .params
        .get("surface")
        .and_then(Value::as_str)
        .ok_or("missing 'surface'")?;
    let surface = crate::sync::parse_surface(surface_str)?;
    let payload = req.params.get("payload").cloned().unwrap_or(Value::Null);
    let scope = crate::sync::parse_scope(&req.params);
    let sub = state.sync_subsystem()?;
    let mut s = sub.lock().await;
    s.append(scope, surface, payload)
}

async fn handle_sync_record_turn(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let conversation_id = req
        .params
        .get("conversation_id")
        .and_then(Value::as_str)
        .unwrap_or("");
    let role = req.params.get("role").and_then(Value::as_str).unwrap_or("user");
    let content = req.params.get("content").and_then(Value::as_str).unwrap_or("");
    let tool_calls = req
        .params
        .get("tool_calls")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let tool_use_id = req.params.get("tool_use_id").and_then(Value::as_str);
    let timestamp = req.params.get("timestamp").and_then(Value::as_u64).unwrap_or(0);
    let scope = crate::sync::parse_scope(&req.params);
    let sub = state.sync_subsystem()?;
    let mut s = sub.lock().await;
    s.record_turn(scope, conversation_id, role, content, tool_calls, tool_use_id, timestamp)
}

async fn handle_sync_record_intent(
    req: &JsonRpcMessage,
    state: &ServerState,
) -> Result<Value, String> {
    let agent_id = req.params.get("agent_id").and_then(Value::as_str).ok_or("missing 'agent_id'")?;
    let run_id = req.params.get("run_id").and_then(Value::as_str).ok_or("missing 'run_id'")?;
    let epoch = req.params.get("epoch").and_then(Value::as_u64).ok_or("missing 'epoch'")?;
    let status_str =
        req.params.get("status").and_then(Value::as_str).ok_or("missing 'status'")?;
    let status = crate::sync::parse_intent_status(status_str)?;
    let intent = car_sync::Intent::new(agent_id, run_id, epoch, status);
    let scope = crate::sync::parse_scope(&req.params);
    let sub = state.sync_subsystem()?;
    let mut s = sub.lock().await;
    s.record_intent(scope, &intent)
}

async fn handle_sync_pump(state: &ServerState) -> Result<Value, String> {
    let sub = state.sync_subsystem()?;
    let mut s = sub.lock().await;
    s.pump()
}

async fn handle_sync_checkpoint(state: &ServerState) -> Result<Value, String> {
    let sub = state.sync_subsystem()?;
    let mut s = sub.lock().await;
    s.checkpoint()
}

async fn handle_sync_rebase(state: &ServerState) -> Result<Value, String> {
    let sub = state.sync_subsystem()?;
    let mut s = sub.lock().await;
    s.rebase()
}

async fn handle_sync_transcript(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let conversation_id = req
        .params
        .get("conversation_id")
        .and_then(Value::as_str)
        .unwrap_or("");
    let sub = state.sync_subsystem()?;
    let s = sub.lock().await;
    Ok(s.transcript(conversation_id))
}

async fn handle_sync_resume(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let conversation_id = req
        .params
        .get("conversation_id")
        .and_then(Value::as_str)
        .unwrap_or("");
    let sub = state.sync_subsystem()?;
    let s = sub.lock().await;
    s.resume(conversation_id)
}

async fn handle_sync_fence_check(
    req: &JsonRpcMessage,
    state: &ServerState,
) -> Result<Value, String> {
    let agent_id = req.params.get("agent_id").and_then(Value::as_str).ok_or("missing 'agent_id'")?;
    let run_id = req.params.get("run_id").and_then(Value::as_str).ok_or("missing 'run_id'")?;
    let epoch = req.params.get("epoch").and_then(Value::as_u64).ok_or("missing 'epoch'")?;
    let sub = state.sync_subsystem()?;
    let mut s = sub.lock().await;
    s.fence_check(agent_id, run_id, epoch)
}

async fn handle_lease_acquire(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let agent_id = req.params.get("agent_id").and_then(Value::as_str).ok_or("missing 'agent_id'")?;
    let ttl_ms = req.params.get("ttl_ms").and_then(Value::as_u64).unwrap_or(30_000);
    let sub = state.sync_subsystem()?;
    let mut s = sub.lock().await;
    s.lease_acquire(agent_id, ttl_ms)
}

async fn handle_lease_renew(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let agent_id = req.params.get("agent_id").and_then(Value::as_str).ok_or("missing 'agent_id'")?;
    let epoch = req.params.get("epoch").and_then(Value::as_u64).ok_or("missing 'epoch'")?;
    let ttl_ms = req.params.get("ttl_ms").and_then(Value::as_u64).unwrap_or(30_000);
    let sub = state.sync_subsystem()?;
    let mut s = sub.lock().await;
    s.lease_renew(agent_id, epoch, ttl_ms)
}

async fn handle_lease_release(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let agent_id = req.params.get("agent_id").and_then(Value::as_str).ok_or("missing 'agent_id'")?;
    let epoch = req.params.get("epoch").and_then(Value::as_u64).ok_or("missing 'epoch'")?;
    let sub = state.sync_subsystem()?;
    let mut s = sub.lock().await;
    s.lease_release(agent_id, epoch)
}

async fn handle_lease_status(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let agent_id = req.params.get("agent_id").and_then(Value::as_str).ok_or("missing 'agent_id'")?;
    let sub = state.sync_subsystem()?;
    let mut s = sub.lock().await;
    s.lease_status(agent_id)
}

// --- permission.* : per-session permission-tier gate (survey §3.4.3/§5.2.5) ---

/// Authority gate for the *mutating* permission methods (set_tier, approve,
/// reject). When the daemon runs under a host token (the CarHost regime,
/// where there is a distinguished human-management client), only that host
/// connection may change a session's tier or record approvals — a
/// registered agent connection must not self-elevate or self-approve, which
/// would make the §5.2.5 gate theater (neo review #3). In pure-dev /
/// embedder mode (no host token configured) the connection governs its own
/// session, the developer being the authority.
fn require_approval_authority(
    session: &crate::session::ClientSession,
    state: &ServerState,
) -> Result<(), String> {
    if state.host_token.get().is_some()
        && !session.is_host.load(std::sync::atomic::Ordering::Acquire)
    {
        return Err("permission tier/approval changes require the host-management role".into());
    }
    Ok(())
}

/// The authenticated principal for this connection, stamped into approval
/// audit records server-side so the "who decided" field can't be forged by
/// the caller (neo review #5c). Prefers the bound agent identity, else the
/// connection id.
async fn session_principal(session: &crate::session::ClientSession) -> String {
    if let Some(agent) = session.agent_id.lock().await.clone() {
        format!("agent:{agent}")
    } else {
        format!("conn:{}", session.client_id)
    }
}

/// Return the session's current granted standing tier.
async fn handle_permission_get_tier(
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let tier = session.permission_gate.read().await.granted_tier();
    Ok(serde_json::json!({ "granted_tier": tier.as_str() }))
}

/// Set the session's granted standing tier. Params: `{ tier }`.
async fn handle_permission_set_tier(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &ServerState,
) -> Result<Value, String> {
    require_approval_authority(session, state)?;
    let tier_str = req
        .params
        .get("tier")
        .and_then(|v| v.as_str())
        .ok_or("missing 'tier'")?;
    let tier = car_policy::PermissionTier::from_str_opt(tier_str)
        .ok_or_else(|| format!("invalid tier '{tier_str}'"))?;
    session.permission_gate.write().await.set_granted_tier(tier);
    Ok(serde_json::json!({ "granted_tier": tier.as_str() }))
}

// --- iMessage approval-transport config surface (`messaging.*`) ---
//
// The host/local-auth-gated config channel for the iMessage approval
// transport (Unit 3). These handlers are the ONLY allowlist/config-mutation
// path in the system. Every one of them rejects a caller that is not
// `session.is_host` and is not presenting the per-launch local auth token —
// the SAME trust root (`require_approval_authority`) that guards all config
// mutation today (permission-tier / approval changes). An inbound iMessage
// carries neither credential, so there is no inbound→config edge anywhere.
//
// DEGRADED-MODE CAVEAT (inherited platform behavior, NOT specific to this
// surface): `require_approval_authority` is a no-op when NO host token is
// configured (pure-dev / `--no-auth` / a failed boot token-write) — in that
// regime the connection governs its own session, the developer being the
// authority, so these `messaging.*` methods become callable by any local
// connection. This is identical to how `permission.*` (set_tier/approve/reject)
// already behaves and is the deliberate platform gate pattern; we do NOT
// diverge from it here. The core anti-injection invariant still holds
// regardless: an inbound iMessage carries no WS session at all, so even in the
// fail-open regime it can never reach these setters — the degraded mode only
// relaxes WHICH local connections are trusted, never opens an inbound→config
// edge.
//
// The store is constructed per-call from `~/.car/` via `HOME` (the same
// pattern `models.register` uses for its per-call `~/.car/models.json`),
// keeping these handlers state-free and side-effect-local.

/// Build the durable messaging config store rooted at the daemon's
/// `~/.car/`. `from_home` resolves `HOME`/`USERPROFILE`.
fn messaging_store() -> crate::messaging_config::MessagingConfigStore {
    crate::messaging_config::MessagingConfigStore::from_home()
}

/// Resolve the optional `channel` string from a request's params to a
/// [`ChannelId`]. **Absent ⇒ iMessage** (back-compat for the #403 surface and
/// the bindings, which have no `channel` field). An unrecognized channel string
/// is an error rather than a silent default (fail-closed on a typo). Reads the
/// top-level `channel` field directly so it works for both the `set` request
/// (which has a typed field) and the param-light `get`/`pairing` methods.
fn messaging_channel_from_params(
    params: &Value,
) -> Result<crate::messaging_config::ChannelId, String> {
    match params.get("channel") {
        None | Some(Value::Null) => Ok(crate::messaging_config::ChannelId::IMessage),
        Some(Value::String(s)) => crate::messaging_config::ChannelId::from_str_opt(s)
            .ok_or_else(|| format!("unknown messaging channel '{s}'")),
        Some(other) => Err(format!("messaging channel must be a string, got {other}")),
    }
}

/// Project a `MessagingConfig` load into the wire view for `channel` (without
/// leaking the active pairing code — that is `messaging.pairing.status` only).
///
/// Per-channel since Unit 2; an absent `channel` in the request defaults to
/// iMessage so the existing 4 methods and their bindings keep working unchanged.
fn messaging_config_view(
    store: &crate::messaging_config::MessagingConfigStore,
    channel: crate::messaging_config::ChannelId,
) -> Result<car_proto::MessagingConfigView, String> {
    let cfg = store.load()?.channel(channel);
    Ok(car_proto::MessagingConfigView {
        channel: channel.as_str().to_string(),
        enabled: cfg.enabled,
        allowlisted_handles: cfg.allowlisted_handles,
        pairing_active: cfg.active_pairing_code.is_some(),
    })
}

/// `messaging.config.get` — return the current transport config view for the
/// (optional) channel (default iMessage). Host/local-auth gated (a read of the
/// trust state is host-only).
async fn handle_messaging_config_get(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &ServerState,
) -> Result<Value, String> {
    require_approval_authority(session, state)?;
    let channel = messaging_channel_from_params(&req.params)?;
    let view = messaging_config_view(&messaging_store(), channel)?;
    serde_json::to_value(view).map_err(|e| e.to_string())
}

/// `messaging.config.set` — mutate the transport config (enabled flag and
/// allowlist) for the (optional) channel (default iMessage). Host/local-auth
/// gated; this is the ONLY allowlist/config mutation path. Params:
/// `MessagingConfigSetRequest`.
async fn handle_messaging_config_set(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &ServerState,
) -> Result<Value, String> {
    require_approval_authority(session, state)?;
    let set: car_proto::MessagingConfigSetRequest = serde_json::from_value(req.params.clone())
        .map_err(|e| format!("invalid messaging.config.set params: {e}"))?;
    // Resolve the channel through the shared `messaging_channel_from_params`
    // (used by all four messaging handlers) rather than `set.channel`: the
    // resolver is the single place that maps absent ⇒ iMessage AND rejects an
    // unknown/non-string `channel` with a clear error. `set.channel` stays a
    // documented part of the request shape (it is what the absent-⇒-iMessage
    // default is named after) but is not re-parsed here.
    let channel = messaging_channel_from_params(&req.params)?;
    let store = messaging_store();
    // Slack token provisioning (MC-9 + MC-6). The `require_approval_authority`
    // host-gate above has ALREADY fired (provisioning is host-only — an inbound
    // message carries no WS session, so it can never reach here). When BOTH
    // `bot_token` + `app_token` are present on the SLACK channel, write the
    // bearer values to the OS keychain via `provision_slack_tokens` and persist
    // ONLY the returned keychain key-REFERENCES into the config — the bearer
    // values never touch `messaging.json` and never echo back in the view.
    // Both-or-neither: a single token present (or the wrong channel) is ignored
    // here so the normal enable/allowlist set stays back-compatible.
    if let (Some(bot), Some(app)) = (set.bot_token.as_deref(), set.app_token.as_deref()) {
        if channel == crate::messaging_config::ChannelId::Slack {
            let secrets = car_secrets::SecretStore::new();
            let refs = crate::slack_adapter::provision_slack_tokens(&secrets, bot, app)
                .map_err(|e| format!("provision slack tokens: {e}"))?;
            store.set_slack_token_ref_for(
                channel,
                crate::messaging_config::SlackTokenRef {
                    bot_token_key: refs.bot_token_key,
                    app_token_key: refs.app_token_key,
                },
            )?;
        }
    }
    // Slack post-channel id (S1). CONFIG, not a secret: the host-gated call
    // persists it INTO `messaging.json` (never the keychain) so the boot path
    // can construct the adapter with the channel to post into. Slack channel
    // only; a non-empty value is required (an empty string would post to
    // `channel:""` → `channel_not_found`).
    if let Some(slack_channel) = set.slack_channel.as_deref() {
        if channel == crate::messaging_config::ChannelId::Slack && !slack_channel.is_empty() {
            store.set_slack_channel_id_for(channel, slack_channel)?;
        }
    }
    if let Some(enabled) = set.enabled {
        // U1: detect an OFF→ON transition so we can spawn the channel's watcher
        // immediately (no daemon/app restart). Read the prior flag BEFORE the
        // mutation; only an actual off→on edge triggers the spawn (an on→on
        // re-set is a no-op via the supervisor's idempotency guard anyway).
        let was_enabled = store.is_enabled_for(channel).unwrap_or(false);
        store.set_enabled_for(channel, enabled)?;
        if enabled && !was_enabled {
            if let Some(sup) = state.channel_supervisor.get() {
                // Spawn the watcher now. A spawn error (e.g. Slack runtime-enable,
                // which is unsupported in this build because its outbound prompt
                // driver is the boot-time FanoutCoordinator) does NOT hard-500 the
                // whole call: config is config, so the enabled flag stays
                // persisted (the user asked to enable it), but the supervisor
                // rolls back its live-set reservation so the channel is NOT marked
                // watcher_running. `messaging.status` then honestly reports
                // `watcher_running:false` — never claiming a channel is live when
                // its outbound cannot fire. The failure is logged, not swallowed
                // silently, and surfaced via `messaging.status` rather than as a
                // transport error on this config write.
                if let Err(e) = sup.ensure_spawned(channel) {
                    tracing::warn!(
                        channel = channel.as_str(),
                        error = %e,
                        "channel enabled in config but its runtime watcher could not start; \
                         messaging.status will report watcher_running:false"
                    );
                }
            }
        }
    }
    if let Some(handles) = set.allowlisted_handles {
        store.set_allowlist_for(channel, handles)?;
    }
    for handle in &set.add_handles {
        store.add_handle_for(channel, handle)?;
    }
    for handle in &set.remove_handles {
        store.remove_handle_for(channel, handle)?;
    }
    let view = messaging_config_view(&store, channel)?;
    serde_json::to_value(view).map_err(|e| e.to_string())
}

/// `messaging.pairing.start` — mint a fresh high-entropy pairing code for the
/// (optional) channel (default iMessage), persist it as that channel's active
/// code, and return it for display ONLY in the local UI. Host/local-auth gated.
/// Rotates any prior active code on that channel.
async fn handle_messaging_pairing_start(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &ServerState,
) -> Result<Value, String> {
    require_approval_authority(session, state)?;
    let channel = messaging_channel_from_params(&req.params)?;
    let store = messaging_store();
    let code = store.mint_pairing_code_for(channel)?;
    let view = messaging_config_view(&store, channel)?;
    let resp = car_proto::MessagingPairingStartResponse {
        pairing_code: code,
        config: view,
    };
    serde_json::to_value(resp).map_err(|e| e.to_string())
}

/// `messaging.pairing.status` — report whether a pairing is in flight on the
/// (optional) channel (default iMessage) and (host/local-auth gated only)
/// re-surface the active code so the local UI can re-display it after a reload.
/// Never exposed over any inbound channel.
async fn handle_messaging_pairing_status(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &ServerState,
) -> Result<Value, String> {
    require_approval_authority(session, state)?;
    let channel = messaging_channel_from_params(&req.params)?;
    let code = messaging_store().active_pairing_code_for(channel)?;
    let resp = car_proto::MessagingPairingStatusResponse {
        pairing_active: code.is_some(),
        pairing_code: code,
    };
    serde_json::to_value(resp).map_err(|e| e.to_string())
}

/// Daemon-side Full Disk Access probe for `messaging.status` (U2, R-B). The
/// daemon is the chat.db reader, so this reflects the DAEMON's access — not the
/// host app's. iMessage probes the real Messages library; other channels carry
/// no chat.db dependency and are always "readable". On non-macOS there is no
/// chat.db, so iMessage is reported readable too (the channel reads nothing
/// there anyway, gated by `cfg(target_os = "macos")` in the adapter).
#[allow(unused_variables)]
fn messaging_fda_readable(channel: crate::messaging_config::ChannelId) -> bool {
    match channel {
        crate::messaging_config::ChannelId::IMessage => {
            #[cfg(target_os = "macos")]
            {
                car_ffi_common::integrations::messages_fda_readable()
            }
            #[cfg(not(target_os = "macos"))]
            {
                true
            }
        }
        crate::messaging_config::ChannelId::Slack => true,
    }
}

/// `messaging.status` — return the real runtime liveness of the (optional)
/// channel (default iMessage) so the host UI can render a SINGLE readiness state
/// (U2). Host/local-auth gated EXACTLY like the other `messaging.*` methods — an
/// inbound message carries no host credential, so it can never call this.
///
/// `enabled` + `paired` come from the config store; `watcher_running` from the
/// runtime channel supervisor's live set (U1); `fda_readable` from a daemon-side
/// probe (R-B); `last_send_*` / `last_error` from the supervisor's per-channel
/// liveness (written by the send path, U3). When the supervisor is not installed
/// (an embedder that never booted the channel pollers), `watcher_running` is
/// `false` and the liveness fields are empty.
async fn handle_messaging_status(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &ServerState,
) -> Result<Value, String> {
    require_approval_authority(session, state)?;
    let channel = messaging_channel_from_params(&req.params)?;
    let store = messaging_store();
    let enabled = store.is_enabled_for(channel).unwrap_or(false);
    let paired = !store.allowlist_for(channel).unwrap_or_default().is_empty();

    let (watcher_running, liveness) = match state.channel_supervisor.get() {
        Some(sup) => (sup.is_spawned(channel), sup.liveness_snapshot(channel)),
        None => (false, crate::channel_supervisor::ChannelLiveness::default()),
    };

    let view = car_proto::MessagingStatusView {
        channel: channel.as_str().to_string(),
        enabled,
        paired,
        watcher_running,
        fda_readable: messaging_fda_readable(channel),
        last_send_at_ms: liveness.last_send_at_ms,
        last_send_ok: liveness.last_send_ok,
        last_error: liveness.last_error,
    };
    serde_json::to_value(view).map_err(|e| e.to_string())
}

/// `messaging.test_send` — send a fixed, clearly-labeled self-test message to
/// the paired handle of the (optional) channel (default iMessage) and return a
/// synchronous `{ ok, error }` (U4). Host/local-auth gated EXACTLY like the
/// other `messaging.*` methods. The self-test mints NO approval/pairing mapping
/// and resolves nothing — it is purely a "does my Mac actually text my phone"
/// probe whose outcome is recorded into liveness (so a pass genuinely proves
/// Automation works and a failure surfaces identically to a real send).
///
/// Reuses the SAME `RealMessageSender` + liveness sink the boot/runtime adapters
/// use, so the test result and `messaging.status`'s `last_send_*` agree. Slack
/// is not yet wired here (the pane this serves is iMessage-only in v1); a Slack
/// test_send returns a clear "not supported on this channel yet" error.
async fn handle_messaging_test_send(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &ServerState,
) -> Result<Value, String> {
    require_approval_authority(session, state)?;
    let channel = messaging_channel_from_params(&req.params)?;

    let result = match channel {
        crate::messaging_config::ChannelId::IMessage => {
            // Build a one-shot orchestrator over the SAME shared liveness the
            // status method reads, so a test send updates "last delivered".
            let liveness = state
                .channel_supervisor
                .get()
                .map(|sup| sup.liveness());
            let base_dir = std::env::var_os("HOME")
                .map(std::path::PathBuf::from)
                .unwrap_or_else(|| std::path::PathBuf::from("."))
                .join(".car");
            let orch = match liveness {
                Some(liveness) => crate::messaging_orchestrator::MessagingOrchestrator::with_liveness(
                    state.host.clone(),
                    messaging_store(),
                    std::sync::Arc::new(crate::messaging_orchestrator::RealMessageSender),
                    base_dir,
                    liveness,
                ),
                None => crate::messaging_orchestrator::MessagingOrchestrator::new(
                    state.host.clone(),
                    messaging_store(),
                    std::sync::Arc::new(crate::messaging_orchestrator::RealMessageSender),
                    base_dir,
                ),
            };
            orch.send_test().await
        }
        crate::messaging_config::ChannelId::Slack => {
            Err("Test send is not supported on the Slack channel yet.".to_string())
        }
    };

    let resp = match result {
        Ok(()) => car_proto::MessagingTestSendResponse {
            ok: true,
            error: None,
        },
        Err(e) => car_proto::MessagingTestSendResponse {
            ok: false,
            error: Some(e),
        },
    };
    serde_json::to_value(resp).map_err(|e| e.to_string())
}

/// Parse a proposal from `params.proposal`.
fn proposal_from_params(req: &JsonRpcMessage) -> Result<car_ir::ActionProposal, String> {
    let p = req.params.get("proposal").ok_or("missing 'proposal'")?;
    serde_json::from_value(p.clone()).map_err(|e| format!("invalid proposal: {e}"))
}

/// Classify each action in a proposal into its required tier. Params:
/// `{ proposal }`. Returns `[{ action_id, tool, required_tier }]`.
async fn handle_permission_classify(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let proposal = proposal_from_params(req)?;
    let gate = session.permission_gate.read().await;
    let rows: Vec<Value> = proposal
        .actions
        .iter()
        .map(|a| {
            serde_json::json!({
                "action_id": a.id,
                "tool": a.tool,
                "required_tier": gate.classifier().classify(a).as_str(),
            })
        })
        .collect();
    Ok(serde_json::json!({ "classifications": rows }))
}

/// Evaluate each action against the session's gate (consulting its durable
/// ledger). Params: `{ proposal }`. Returns `[{ action_id, fingerprint,
/// decision, ... }]`.
async fn handle_permission_evaluate(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &ServerState,
) -> Result<Value, String> {
    let proposal = proposal_from_params(req)?;
    let ceiling = resolve_skill_ceiling(req, session).await;
    let gate = session.permission_gate.read().await;
    // Prior decisions come from the SHARED daemon ledger (C1) — an approval
    // recorded on another connection must be honoured here.
    let ledger = state.approval_ledger.read().await;
    let rows = evaluate_actions(&gate, &proposal, ceiling, &ledger);
    let mut resp = serde_json::json!({ "decisions": rows });
    if let Some(c) = ceiling {
        resp.as_object_mut()
            .unwrap()
            .insert("skill_ceiling".into(), serde_json::json!(c.as_str()));
    }
    Ok(resp)
}

/// Like evaluate but returns only the actions that need a human decision —
/// the work queue for an approval UI.
async fn handle_permission_pending(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &ServerState,
) -> Result<Value, String> {
    let proposal = proposal_from_params(req)?;
    let ceiling = resolve_skill_ceiling(req, session).await;
    let gate = session.permission_gate.read().await;
    let ledger = state.approval_ledger.read().await;
    let pending: Vec<Value> = evaluate_actions(&gate, &proposal, ceiling, &ledger)
        .into_iter()
        .filter(|r| r.get("decision").and_then(|v| v.as_str()) == Some("needs_approval"))
        .collect();
    Ok(serde_json::json!({ "pending": pending }))
}

/// Resolve the optional skill-deployment ceiling for a permission evaluation: if
/// the request names a `skill`, look up its persisted `deployment_tier` (the
/// ceiling the skill-trust gate stamped at load time) so the action-level gate
/// caps standing authority at it. `None` when no skill is named, the skill is
/// absent, or it carries no governed tier — leaving evaluation unchanged.
async fn resolve_skill_ceiling(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Option<car_policy::PermissionTier> {
    let skill = req.params.get("skill").and_then(|v| v.as_str())?;
    let engine = session.memgine.lock().await;
    engine.skill_meta(skill).and_then(|m| m.deployment_tier)
}

fn evaluate_actions(
    gate: &car_policy::PermissionGate,
    proposal: &car_ir::ActionProposal,
    ceiling: Option<car_policy::PermissionTier>,
    ledger: &car_policy::ApprovalLedger,
) -> Vec<Value> {
    proposal
        .actions
        .iter()
        .map(|a| {
            let decision = gate.evaluate_with_ceiling_against(a, ceiling, ledger);
            let mut obj = serde_json::to_value(&decision).unwrap_or(Value::Null);
            if let Some(map) = obj.as_object_mut() {
                map.insert("action_id".into(), serde_json::json!(a.id));
                map.insert(
                    "fingerprint".into(),
                    serde_json::json!(car_policy::action_fingerprint(a)),
                );
            }
            obj
        })
        .collect()
}

/// Record a durable human-in-the-loop decision. Params either
/// `{ fingerprint, required_tier, reviewer, reason, evidence? }` (from a
/// prior `permission.evaluate`) or `{ action, reviewer, reason, evidence? }`.
/// Also audited to the session event log as `ApprovalRecorded`.
async fn handle_permission_decision(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &ServerState,
    approve: bool,
) -> Result<Value, String> {
    require_approval_authority(session, state)?;
    // The reviewer is the server-stamped principal, NOT a caller string —
    // an audit log whose "who approved" is forgeable by the approver
    // undercuts §5.2.5 (neo review #5c). The caller's free-text note is
    // kept as the reason.
    let reviewer = session_principal(session).await;
    let reason = req
        .params
        .get("reason")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let evidence = req
        .params
        .get("evidence")
        .and_then(|v| v.as_str())
        .map(str::to_string);

    // Decisions land on the SHARED daemon ledger (kernel review C1): the
    // approver is typically a host connection while the runner that surfaced
    // the fingerprint is another — a per-session ledger would strand the
    // decision where the runner never reads it. The session gate contributes
    // only its classifier (for the action-shaped variant).
    let decision = if approve {
        car_policy::ApprovalDecision::Approved
    } else {
        car_policy::ApprovalDecision::Rejected
    };
    let record = if let Some(action_val) = req.params.get("action") {
        let action: car_ir::Action = serde_json::from_value(action_val.clone())
            .map_err(|e| format!("invalid action: {e}"))?;
        let rec = {
            let gate = session.permission_gate.read().await;
            gate.decision_record(&action, decision, &reviewer, reason, evidence)
        };
        state
            .approval_ledger
            .write()
            .await
            .record(rec.clone())
            // Journal write failure = decision NOT durable; propagate instead
            // of emitting a false ApprovalRecorded audit event (review A7).
            .map_err(|e| format!("failed to persist approval decision: {e}"))?;
        rec
    } else {
        let fingerprint = req
            .params
            .get("fingerprint")
            .and_then(|v| v.as_str())
            .ok_or("missing 'fingerprint' (or 'action')")?;
        let required_tier = req
            .params
            .get("required_tier")
            .and_then(|v| v.as_str())
            .and_then(car_policy::PermissionTier::from_str_opt)
            .unwrap_or(car_policy::PermissionTier::FullAccess);
        state
            .approval_ledger
            .write()
            .await
            .record_decision(fingerprint, required_tier, decision, &reviewer, reason, evidence)
            .map_err(|e| format!("failed to persist approval decision: {e}"))?
    };

    // Audit the durable transition (§5.2.5) to the session event log — at
    // parity with car-engine's TierPermissionHandler emission (required_tier
    // + evidence; approval derived from the stored decision, not the request).
    let mut data = std::collections::HashMap::new();
    data.insert(
        "fingerprint".to_string(),
        Value::from(record.fingerprint.clone()),
    );
    data.insert(
        "approval".to_string(),
        Value::from(match record.decision {
            car_policy::ApprovalDecision::Approved => "approved",
            car_policy::ApprovalDecision::Rejected => "rejected",
        }),
    );
    data.insert(
        "required_tier".to_string(),
        Value::from(record.required_tier.as_str()),
    );
    data.insert("reviewer".to_string(), Value::from(record.reviewer.clone()));
    data.insert("reason".to_string(), Value::from(record.reason.clone()));
    if let Some(ev) = &record.evidence {
        data.insert("evidence".to_string(), Value::from(ev.clone()));
    }
    session.runtime.log.lock().await.append(
        car_eventlog::EventKind::ApprovalRecorded,
        None,
        None,
        data,
    );

    serde_json::to_value(&record).map_err(|e| e.to_string())
}

/// Validate a tenant id (linus review C-4). `:` is the namespace
/// separator in `tenant:<id>:<key>`, so an id containing it nests
/// inside (or envelops) another tenant's namespace — `"acme:sub"`
/// would read acme's keys and be captured by acme's scoped
/// snapshot/restore/reap. Reject at the boundary.
fn validate_tenant_id(id: &str) -> Result<(), String> {
    if id.contains(':') {
        return Err(format!(
            "invalid tenant_id '{id}': ':' is reserved as the namespace separator"
        ));
    }
    Ok(())
}

/// Resolve the tenant identity for a tenant-scoped handler
/// (Parslee-ai/car#187 phase 3-E, hardened per linus review C-4).
///
/// The session's bound tenant (from `session.auth { tenant_id }`) is
/// authoritative: when bound, a per-request `tenant_id` may restate it
/// but a mismatch is an error — request params are caller-controlled
/// and must not hop namespaces. An unbound session keeps the legacy
/// per-request behavior (validated). Absent/empty → unscoped.
async fn effective_tenant(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Option<String>, String> {
    let param = req
        .params
        .get("tenant_id")
        .and_then(|v| v.as_str())
        .filter(|s| !s.is_empty())
        .map(str::to_string);
    if let Some(p) = &param {
        validate_tenant_id(p)?;
    }
    let bound = session.tenant.lock().await.clone();
    match (bound, param) {
        (Some(b), Some(p)) if b != p => Err(format!(
            "tenant_id '{p}' conflicts with this session's bound tenant '{b}'"
        )),
        (Some(b), _) => Ok(Some(b)),
        (None, p) => Ok(p),
    }
}

/// Refuse unscoped access to a tenant-namespaced key (linus review
/// C-4): without this, `state.get {key: "tenant:acme:secret"}` with no
/// `tenant_id` read (and `state.set` wrote) straight through another
/// tenant's namespace — enforcement existed only on `keys`/`snapshot`.
fn reject_unscoped_tenant_key(tenant: &Option<String>, key: &str) -> Result<(), String> {
    if tenant.is_none() && key.starts_with("tenant:") {
        return Err(format!(
            "unscoped access to tenant-namespaced key '{key}' is not permitted; \
             authenticate with the owning tenant_id"
        ));
    }
    Ok(())
}

async fn handle_state_get(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let key = req
        .params
        .get("key")
        .and_then(|v| v.as_str())
        .ok_or("missing 'key'")?;
    let tenant = effective_tenant(req, session).await?;
    reject_unscoped_tenant_key(&tenant, key)?;
    Ok(session
        .runtime
        .state
        .scoped(tenant.as_deref())
        .get(key)
        .unwrap_or(Value::Null))
}

async fn handle_state_set(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let key = req
        .params
        .get("key")
        .and_then(|v| v.as_str())
        .ok_or("missing 'key'")?;
    let value = req.params.get("value").cloned().unwrap_or(Value::Null);
    let tenant = effective_tenant(req, session).await?;
    reject_unscoped_tenant_key(&tenant, key)?;
    session
        .runtime
        .state
        .scoped(tenant.as_deref())
        .set(key, value, "client");
    Ok(Value::from("ok"))
}

/// `state.exists` — true if the key is set in this session's state
/// store, false otherwise. Cheaper than `state.get` + null-check on
/// the client side because it doesn't serialize the value.
async fn handle_state_exists(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let key = req
        .params
        .get("key")
        .and_then(|v| v.as_str())
        .ok_or("missing 'key'")?;
    let tenant = effective_tenant(req, session).await?;
    reject_unscoped_tenant_key(&tenant, key)?;
    Ok(Value::Bool(
        session.runtime.state.scoped(tenant.as_deref()).exists(key),
    ))
}

/// `state.keys` — list every key currently set in this session's
/// state store. Returns a JSON array of strings.
async fn handle_state_keys(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let tenant = effective_tenant(req, session).await?;
    Ok(Value::Array(
        session
            .runtime
            .state
            .scoped(tenant.as_deref())
            .keys()
            .into_iter()
            .map(Value::String)
            .collect(),
    ))
}

/// `state.snapshot` — return the entire session state store as a
/// JSON object (`{ key: value, ... }`). Equivalent to iterating
/// `state.keys` + `state.get` but in a single round-trip; for
/// inspectors/dashboards.
///
/// Tenant-scoped variant: when `tenant_id` is set, only that
/// tenant's keys are returned (prefix stripped on the way out).
/// `state.snapshot` with no `tenant_id` returns only unscoped
/// keys; consistent with `state.keys`'s filter behaviour and the
/// strict-isolation contract from phase 3-B.
async fn handle_state_snapshot(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let tenant = effective_tenant(req, session).await?;
    let view = session.runtime.state.scoped(tenant.as_deref());
    let mut map = serde_json::Map::new();
    for key in view.keys() {
        if let Some(value) = view.get(&key) {
            map.insert(key, value);
        }
    }
    Ok(Value::Object(map))
}

// --- Per-agent persistent memgine (#170) ---

/// `~/.car/memory/agents/<id>.json` — the per-agent snapshot file.
/// Mirrors the existing `memory.persist` shape (flat JSON array of
/// fact objects) so the same loader path works.
fn agent_memgine_snapshot_path(agent_id: &str) -> Result<std::path::PathBuf, String> {
    let base = car_ffi_common::memory_path::ensure_base()
        .map_err(|e| format!("memory base unavailable: {e}"))?;
    let dir = base.join("agents");
    std::fs::create_dir_all(&dir).map_err(|e| format!("create agents dir: {e}"))?;
    Ok(dir.join(format!("{agent_id}.json")))
}

/// Acquire (or lazy-create + load from disk) the daemon-owned
/// persistent memgine for `agent_id`. First call per id reads
/// `~/.car/memory/agents/<id>.json` if it exists; subsequent calls
/// share the in-memory engine across sessions. Caller stores the
/// returned `Arc` on `ClientSession.bound_memgine` so memory.*
/// handlers route through it via [`ClientSession::effective_memgine`].
/// Resolve the [`car_memgine::MemgineConfig`] the daemon seeds its engines with:
/// discover the `.car/` project from the **project anchor** and apply its
/// `config.toml` overrides (e.g. utility-aware retrieval). The anchor is
/// `$CAR_PROJECT_DIR` when set, else the process cwd.
///
/// The env var is load-bearing for the shipped macOS path: the
/// `launchd`/CarHost-supervised `car-server` inherits the app bundle (or `/`)
/// as cwd, which has no `.car/` ancestor — so cwd-only discovery would silently
/// no-op there. A host that wants project tuning points `CAR_PROJECT_DIR` at the
/// workspace. With neither anchor resolvable, returns the default config
/// (utility retrieval off) — a safe no-op, never a panic.
pub fn seed_memgine_config() -> car_memgine::MemgineConfig {
    let anchor = std::env::var_os("CAR_PROJECT_DIR")
        .map(std::path::PathBuf::from)
        .or_else(|| std::env::current_dir().ok());
    match anchor {
        Some(dir) => {
            car_memgine::project::resolve_config(&dir, car_memgine::MemgineConfig::default())
        }
        None => car_memgine::MemgineConfig::default(),
    }
}

async fn get_or_load_agent_memgine(
    state: &Arc<ServerState>,
    agent_id: &str,
) -> Result<Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>, String> {
    {
        let map = state.agent_memgines.lock().await;
        if let Some(eng) = map.get(agent_id) {
            return Ok(eng.clone());
        }
    }
    // Build a fresh engine and try to load from disk. Seed its config from the
    // `.car/` project (like the daemon's shared engine) so a bound agent's
    // retrieval honors the same team-shared tuning (e.g. utility-aware
    // retrieval) — otherwise the knob could never reach the per-agent engine
    // (#170 bound_memgine path).
    let engine = Arc::new(tokio::sync::Mutex::new(car_memgine::MemgineEngine::new(Some(
        seed_memgine_config(),
    ))));
    let path = agent_memgine_snapshot_path(agent_id)?;
    if path.exists() {
        let content = std::fs::read_to_string(&path)
            .map_err(|e| format!("read {}: {}", path.display(), e))?;
        let facts: Vec<Value> = serde_json::from_str(&content).unwrap_or_default();
        let mut g = engine.lock().await;
        let mut loaded: u32 = 0;
        for fact in &facts {
            let subject = fact.get("subject").and_then(|v| v.as_str()).unwrap_or("");
            let body = fact.get("body").and_then(|v| v.as_str()).unwrap_or("");
            let kind = fact
                .get("kind")
                .and_then(|v| v.as_str())
                .unwrap_or("pattern");
            let fid = format!("loaded-{loaded}");
            g.ingest_fact(
                &fid,
                subject,
                body,
                "user",
                "peer",
                chrono::Utc::now(),
                "global",
                None,
                vec![],
                kind == "constraint",
            );
            loaded += 1;
        }
    }
    let mut map = state.agent_memgines.lock().await;
    let stored = map.entry(agent_id.to_string()).or_insert(engine).clone();
    Ok(stored)
}

/// Crash- and concurrency-safe write: serialize to a UNIQUE temp in the same
/// directory, then atomically rename over the target. A crash mid-write or a
/// concurrent writer to the same (client-chosen) path can no longer leave a
/// partial or interleaved file — `rename(2)` is atomic on the same filesystem,
/// and the per-call temp name (pid + monotonic seq) means two writers never
/// collide on the temp. Synchronous — call inside spawn_blocking.
fn atomic_write_sync(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
    use std::sync::atomic::{AtomicU64, Ordering};
    static SEQ: AtomicU64 = AtomicU64::new(0);
    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
    let mut tmp_os = path.as_os_str().to_owned();
    tmp_os.push(format!(".tmp.{}.{}", std::process::id(), seq));
    let tmp = std::path::PathBuf::from(tmp_os);
    std::fs::write(&tmp, bytes)?;
    std::fs::rename(&tmp, path)
}

/// Snapshot the agent's memgine to its disk file. Same on-wire shape
/// as `memory.persist` so manual snapshots and the daemon-owned
/// persistence stay interoperable.
async fn persist_agent_memgine(
    agent_id: &str,
    engine: &Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>,
) -> Result<(), String> {
    let path = agent_memgine_snapshot_path(agent_id)?;
    let g = engine.lock().await;
    let facts: Vec<Value> = g
        .graph
        .inner
        .node_indices()
        .filter_map(|nix| {
            let node = g.graph.inner.node_weight(nix)?;
            if !node.is_valid() {
                return None;
            }
            if node.kind == car_memgine::MemKind::Identity
                || node.kind == car_memgine::MemKind::Environment
            {
                return None;
            }
            Some(serde_json::json!({
                "subject": node.key,
                "body": node.value,
                "kind": match node.kind {
                    car_memgine::MemKind::Fact => if node.is_constraint { "constraint" } else { "pattern" },
                    car_memgine::MemKind::Conversation => "outcome",
                    _ => "pattern",
                },
                "confidence": 0.5,
                "content_type": node.content_type.as_label(),
            }))
        })
        .collect();
    // Release the SHARED memgine lock before serialize+write, then do that work
    // on the blocking pool: holding the lock across a blocking write serializes
    // every agent's memory ops and a slow/stuck write would wedge them all
    // (the incident). spawn_blocking also keeps the write off the tokio worker
    // (no thread-pinning) and lets the per-request deadline preempt at the await.
    drop(g);
    let path_owned = path.clone();
    tokio::task::spawn_blocking(move || -> Result<(), String> {
        let json = serde_json::to_string(&facts).map_err(|e| e.to_string())?;
        atomic_write_sync(&path_owned, json.as_bytes())
            .map_err(|e| format!("write {}: {}", path_owned.display(), e))
    })
    .await
    .map_err(|e| format!("persist_agent_memgine join: {e}"))?
}

// --- Memory handlers ---

/// `memory.fact_count` — return `valid_fact_count()` of the
/// session's memgine. Used by FFI bindings to mirror their
/// embedded `fact_count()` accessor without round-tripping a full
/// query. No params.
async fn handle_memory_fact_count(
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let engine_arc = session.effective_memgine().await;
    let engine = engine_arc.lock().await;
    Ok(Value::from(engine.valid_fact_count()))
}

async fn handle_memory_add_fact(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let subject = req
        .params
        .get("subject")
        .and_then(|v| v.as_str())
        .ok_or("missing subject")?;
    let body = req
        .params
        .get("body")
        .and_then(|v| v.as_str())
        .ok_or("missing body")?;
    let kind = req
        .params
        .get("kind")
        .and_then(|v| v.as_str())
        .unwrap_or("pattern");
    // Route through `effective_memgine` so connections bound to a
    // lifecycle agent (#169) write into the daemon-owned per-agent
    // memgine instead of the per-WS ephemeral one (#170).
    let engine_arc = session.effective_memgine().await;
    let count = {
        let mut engine = engine_arc.lock().await;
        let fid = format!("ws-{}", engine.valid_fact_count());
        engine.ingest_fact(
            &fid,
            subject,
            body,
            "user",
            "peer",
            chrono::Utc::now(),
            "global",
            None,
            vec![],
            kind == "constraint",
        );
        engine.valid_fact_count()
    };
    // Persist after every add when the session is bound to a
    // supervised agent. Synchronous write — small JSON snapshot.
    if let Some(id) = session.agent_id.lock().await.clone() {
        if let Err(e) = persist_agent_memgine(&id, &engine_arc).await {
            tracing::warn!(agent_id = %id, error = %e,
                "agent memgine persist failed; in-memory state is canonical");
        }
    }
    Ok(Value::from(count))
}

async fn handle_memory_query(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let query = req
        .params
        .get("query")
        .and_then(|v| v.as_str())
        .ok_or("missing query")?;
    let k = req.params.get("k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
    let engine_arc = session.effective_memgine().await;
    let engine = engine_arc.lock().await;
    let seeds = engine.graph.find_seeds(query, 5);
    // FFI parity with NAPI `query_facts` (car-ffi-napi/src/lib.rs:577) —
    // both use Personalized PageRank so transport choice doesn't shift
    // ranking semantics. Result shape (subject/body/kind/confidence)
    // also mirrors NAPI for the same reason.
    let hits = if !seeds.is_empty() {
        engine.graph.retrieve_ppr(&seeds, None, 0.5, k)
    } else {
        vec![]
    };
    let results: Vec<Value> = hits
        .iter()
        .filter_map(|hit| {
            let node = engine.graph.inner.node_weight(hit.node_ix)?;
            Some(serde_json::json!({
                "subject": node.key,
                "body": node.value,
                "kind": format!("{:?}", node.kind).to_lowercase(),
                "confidence": hit.activation,
            }))
        })
        .collect();
    serde_json::to_value(results).map_err(|e| e.to_string())
}

async fn handle_memory_build_context(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let query = req
        .params
        .get("query")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    // FFI parity with NAPI `build_context(query, model_context_window)`.
    // When supplied, sizes the assembly budget against the model's window
    // instead of the fixed 8K default.
    let model_context_window = req
        .params
        .get("model_context_window")
        .and_then(|v| v.as_u64())
        .map(|w| w as usize);
    let mut engine = session.memgine.lock().await;
    Ok(Value::from(
        engine.build_context_for_model(query, model_context_window),
    ))
}

/// `memory.build_context_fast` — Fast-mode context assembly for
/// latency-sensitive paths (voice, real-time). Skips embedding flush,
/// skill lookup, PPR-based scoring, inline repairs, known-unknowns
/// extraction. Keeps identity, constraints, facts (creation order),
/// conversation, environment.
async fn handle_memory_build_context_fast(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let query = req
        .params
        .get("query")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let model_context_window = req
        .params
        .get("model_context_window")
        .and_then(|v| v.as_u64())
        .map(|w| w as usize);
    let mut engine = session.memgine.lock().await;
    Ok(Value::from(engine.build_context_with_options(
        query,
        model_context_window,
        car_memgine::ContextMode::Fast,
        None,
    )))
}

/// `memory.persist` — write the session's memgine to a JSON file
/// at `path`. Mirrors NAPI `persist_memory` (car-ffi-napi/src/lib.rs:797)
/// so daemon-mode clients can drive checkpoint/restore symmetrically
/// with embedded mode. Returns the number of facts written.
///
/// Filesystem caveat: `path` is interpreted on the daemon's filesystem,
/// not the caller's. Since the 2026-05 audit, `path` is also
/// sandboxed under `~/.car/memory/` via
/// [`car_ffi_common::memory_path::resolve`] — relative paths land
/// under the base, absolute paths must already be under the base,
/// `..` segments are rejected, symlinks pointing out are rejected.
/// Pre-2026-05 the path was passed straight to `std::fs::write` and
/// became an arbitrary file-write primitive. The base64-blob escape
/// hatch tracked in `Parslee-ai/car-releases#31` will plug into the
/// same resolver when it lands.
/// Protocol-version handshake. Purely advertises this daemon's wire protocol
/// version + semver; the CLIENT decides compatibility (it knows which server
/// range it needs). Additive and backward-safe: old clients never call it; a
/// new client calling it against an OLD daemon gets the standard
/// "unknown method" error and degrades gracefully.
fn handle_server_handshake(req: &JsonRpcMessage) -> Result<Value, String> {
    let client_protocol = req.params.get("protocol_version").and_then(|v| v.as_u64());
    Ok(serde_json::json!({
        "protocol_version": car_proto::PROTOCOL_VERSION,
        "server_version": env!("CARGO_PKG_VERSION"),
        "client_protocol_version": client_protocol,
    }))
}

async fn handle_memory_persist(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let path = req
        .params
        .get("path")
        .and_then(|v| v.as_str())
        .ok_or("missing path")?;
    let resolved = car_ffi_common::memory_path::resolve(path)
        .map_err(|e| format!("memory.persist rejected path {path:?}: {e}"))?;
    let engine = session.memgine.lock().await;
    let facts: Vec<Value> = engine
        .graph
        .inner
        .node_indices()
        .filter_map(|nix| {
            let node = engine.graph.inner.node_weight(nix)?;
            if !node.is_valid() {
                return None;
            }
            if node.kind == car_memgine::MemKind::Identity
                || node.kind == car_memgine::MemKind::Environment
            {
                return None;
            }
            Some(serde_json::json!({
                "subject": node.key,
                "body": node.value,
                "kind": match node.kind {
                    car_memgine::MemKind::Fact => if node.is_constraint { "constraint" } else { "pattern" },
                    car_memgine::MemKind::Conversation => "outcome",
                    _ => "pattern",
                },
                "confidence": 0.5,
                "content_type": node.content_type.as_label(),
            }))
        })
        .collect();
    let count = facts.len();
    // Release the shared memgine lock, then serialize+write on the blocking pool
    // (see persist_agent_memgine): never hold the lock across blocking I/O, keep
    // the write off the tokio worker, and let the deadline preempt at the await.
    drop(engine);
    let resolved_owned = resolved.clone();
    tokio::task::spawn_blocking(move || -> Result<(), String> {
        let json = serde_json::to_string(&facts).map_err(|e| e.to_string())?;
        atomic_write_sync(&resolved_owned, json.as_bytes())
            .map_err(|e| format!("failed to write {}: {}", resolved_owned.display(), e))
    })
    .await
    .map_err(|e| format!("memory.persist join: {e}"))??;
    Ok(Value::from(count as u64))
}

/// `memory.load` — replace the session's memgine with facts from the
/// JSON file at `path`. Mirrors NAPI `load_memory`
/// (car-ffi-napi/src/lib.rs:121). Same `~/.car/memory/` sandboxing
/// as `memory.persist` since the 2026-05 audit — relative paths
/// land under the base, anything that escapes is rejected.
async fn handle_memory_load(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let path = req
        .params
        .get("path")
        .and_then(|v| v.as_str())
        .ok_or("missing path")?;
    let resolved = car_ffi_common::memory_path::resolve(path)
        .map_err(|e| format!("memory.load rejected path {path:?}: {e}"))?;
    let content = std::fs::read_to_string(&resolved)
        .map_err(|e| format!("failed to read {}: {}", resolved.display(), e))?;
    let facts: Vec<Value> =
        serde_json::from_str(&content).map_err(|e| format!("invalid JSON: {}", e))?;
    let mut engine = session.memgine.lock().await;
    engine.reset();
    let mut count: u32 = 0;
    for fact in &facts {
        let subject = fact.get("subject").and_then(|v| v.as_str()).unwrap_or("");
        let body = fact.get("body").and_then(|v| v.as_str()).unwrap_or("");
        let kind = fact
            .get("kind")
            .and_then(|v| v.as_str())
            .unwrap_or("pattern");
        let fid = format!("loaded-{}", count);
        engine.ingest_fact(
            &fid,
            subject,
            body,
            "user",
            "peer",
            chrono::Utc::now(),
            "global",
            None,
            vec![],
            kind == "constraint",
        );
        count += 1;
    }
    Ok(Value::from(count))
}

// --- Skill handlers ---

async fn handle_skill_ingest(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let name = req
        .params
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or("missing name")?;
    let code = req
        .params
        .get("code")
        .and_then(|v| v.as_str())
        .ok_or("missing code")?;
    let platform = req
        .params
        .get("platform")
        .and_then(|v| v.as_str())
        .unwrap_or("unknown");
    let persona = req
        .params
        .get("persona")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let url_pattern = req
        .params
        .get("url_pattern")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let description = req
        .params
        .get("description")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let supersedes = req.params.get("supersedes").and_then(|v| v.as_str());
    let keywords: Vec<String> = req
        .params
        .get("task_keywords")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();

    let trigger = car_memgine::SkillTrigger {
        persona: persona.into(),
        url_pattern: url_pattern.into(),
        task_keywords: keywords,
        structured: None,
    };
    let mut engine = session.memgine.lock().await;
    let node = engine.ingest_skill(
        name,
        code,
        platform,
        trigger,
        description,
        supersedes,
        vec![],
        vec![],
    );
    Ok(Value::from(node.index() as u64))
}

async fn handle_skill_find(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let persona = req
        .params
        .get("persona")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let url = req.params.get("url").and_then(|v| v.as_str()).unwrap_or("");
    let task = req
        .params
        .get("task")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let max = req
        .params
        .get("max_results")
        .and_then(|v| v.as_u64())
        .unwrap_or(1) as usize;
    // Tenant-scoped matching (linus review C-3): a bound/param tenant
    // sees only its own skills; an unscoped caller sees only unscoped
    // skills. Strict isolation over the WS boundary, consistent with
    // state.keys/state.snapshot — the legacy see-everything find_skill
    // remains available to in-process embedders only.
    let tenant = effective_tenant(req, session).await?;
    let engine = session.memgine.lock().await;
    let results = engine.find_skill_scoped(persona, url, task, max, tenant.as_deref());
    let json: Vec<Value> = results
        .iter()
        .map(|(m, s)| {
            serde_json::json!({
                "name": m.name, "code": m.code, "platform": m.platform,
                "description": m.description, "stats": m.stats, "match_score": s,
            })
        })
        .collect();
    serde_json::to_value(json).map_err(|e| e.to_string())
}

async fn handle_skill_report(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let name = req
        .params
        .get("skill_name")
        .and_then(|v| v.as_str())
        .ok_or("missing skill_name")?;
    let outcome_str = req
        .params
        .get("outcome")
        .and_then(|v| v.as_str())
        .ok_or("missing outcome")?;
    let outcome = match outcome_str {
        "success" => car_memgine::SkillOutcome::Success,
        _ => car_memgine::SkillOutcome::Fail,
    };
    // Tenant-exact resolution (linus review): one tenant's outcome
    // reports must not degrade another tenant's same-named skill.
    let tenant = effective_tenant(req, session).await?;
    let mut engine = session.memgine.lock().await;
    let stats = engine
        .report_outcome_scoped(name, outcome, tenant.as_deref())
        .ok_or(format!("skill '{}' not found", name))?;
    serde_json::to_value(stats).map_err(|e| e.to_string())
}

/// Gate a skill's deployment capability against its provenance, folding the
/// named skill's **live** track record into the decision (arXiv 2602.12430
/// "Agent Skills"; `docs/proposals/skill-trust-governance.md`). The host
/// supplies the static `provenance` (signature/scan/source — typically built
/// from a `car-bundle` manifest via `assess_signature_trust`) and the
/// `requested_tier`; the engine overrides the lifecycle counts with the skill's
/// real `success_count`/`fail_count`, so a skill failing in the field is denied
/// despite an official signature. Returns the `SkillDeploymentDecision` JSON.
async fn handle_skill_gate_deployment(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let name = req
        .params
        .get("skill_name")
        .and_then(|v| v.as_str())
        .ok_or("missing skill_name")?;
    let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
        req.params
            .get("provenance")
            .cloned()
            .unwrap_or_else(|| serde_json::json!({})),
    )
    .map_err(|e| format!("invalid provenance: {e}"))?;
    let tier_str = req
        .params
        .get("requested_tier")
        .and_then(|v| v.as_str())
        .ok_or("missing requested_tier")?;
    let requested = car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(
        || {
            format!(
                "invalid requested_tier '{tier_str}' \
                 (expected read_only|sandbox_edit|full_access)"
            )
        },
    )?;
    let engine = session.memgine.lock().await;
    let decision = engine.gate_skill_deployment(name, provenance, requested);
    serde_json::to_value(decision).map_err(|e| e.to_string())
}

/// Enforce a skill's deployment at load time against the session's durable
/// `ApprovalLedger` — the HITL bridge (arXiv 2602.12430 "Agent Skills" Slice 4;
/// `docs/proposals/skill-trust-governance.md`). Gates the skill (folding its
/// live track record), then resolves the verdict against standing operator
/// decisions: `Allow`/`Downgrade` deploy autonomously (a downgrade is the gate's
/// own safe mitigation), a `Deny` is overridden if an operator previously
/// approved it, blocked if rejected, or surfaced as a `pending_approval` the host
/// routes through the existing `permission.approve`/`permission.reject` flow by
/// the returned `fingerprint`. Returns `{ decision, enforcement, pending_approval? }`.
async fn handle_skill_enforce_deployment(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &ServerState,
) -> Result<Value, String> {
    let name = req
        .params
        .get("skill_name")
        .and_then(|v| v.as_str())
        .ok_or("missing skill_name")?;
    let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
        req.params
            .get("provenance")
            .cloned()
            .unwrap_or_else(|| serde_json::json!({})),
    )
    .map_err(|e| format!("invalid provenance: {e}"))?;
    let tier_str = req
        .params
        .get("requested_tier")
        .and_then(|v| v.as_str())
        .ok_or("missing requested_tier")?;
    let requested = car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(
        || {
            format!(
                "invalid requested_tier '{tier_str}' \
                 (expected read_only|sandbox_edit|full_access)"
            )
        },
    )?;

    // Gate first (folds the skill's live success/fail record into the verdict).
    let decision = {
        let engine = session.memgine.lock().await;
        engine.gate_skill_deployment(name, provenance, requested)
    };
    // Then enforce against the daemon's SHARED durable HITL ledger — the same
    // ledger `permission.*` records decisions to, from ANY connection (C1).
    let enforcement = {
        let ledger = state.approval_ledger.read().await;
        car_policy::skill_trust::enforce_deployment(&decision, name, requested, &ledger)
    };

    // Extract the pending fingerprint before the values are moved into the
    // response (so the host can resolve it via permission.approve/reject).
    let pending_json = enforcement.pending.as_ref().map(|p| {
        serde_json::json!({
            "fingerprint": p.fingerprint,
            "skill_name": p.skill_name,
            "requested_tier": requested.as_str(),
        })
    });
    let mut resp = serde_json::json!({ "decision": decision, "enforcement": enforcement });
    if let Some(pj) = pending_json {
        resp.as_object_mut()
            .unwrap()
            .insert("pending_approval".into(), pj);
    }
    Ok(resp)
}

/// Ingest a skill **through the deployment gate** (arXiv 2602.12430 "Agent
/// Skills" — the loader integration). Gates the skill's provenance against the
/// requested capability, enforces the verdict against the session's durable
/// `ApprovalLedger`, and **only adds the skill to the graph when deployment is
/// permitted** — stamping the granted ceiling onto the skill so later execution
/// honours it. A denied skill is not ingested; an unseen deny surfaces a
/// `pending_approval` resolved through `permission.approve`/`permission.reject`.
/// Same flat skill fields as `skill.ingest`, plus `provenance?` and
/// `requested_tier`. Returns `{ ingested, node?, decision, enforcement,
/// pending_approval? }`.
async fn handle_skill_ingest_governed(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &ServerState,
) -> Result<Value, String> {
    let name = req
        .params
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or("missing name")?;
    let code = req
        .params
        .get("code")
        .and_then(|v| v.as_str())
        .ok_or("missing code")?;
    let platform = req
        .params
        .get("platform")
        .and_then(|v| v.as_str())
        .unwrap_or("unknown");
    let persona = req
        .params
        .get("persona")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let url_pattern = req
        .params
        .get("url_pattern")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let description = req
        .params
        .get("description")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let supersedes = req.params.get("supersedes").and_then(|v| v.as_str());
    let keywords: Vec<String> = req
        .params
        .get("task_keywords")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();
    let trigger = car_memgine::SkillTrigger {
        persona: persona.into(),
        url_pattern: url_pattern.into(),
        task_keywords: keywords,
        structured: None,
    };

    let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
        req.params
            .get("provenance")
            .cloned()
            .unwrap_or_else(|| serde_json::json!({})),
    )
    .map_err(|e| format!("invalid provenance: {e}"))?;
    let tier_str = req
        .params
        .get("requested_tier")
        .and_then(|v| v.as_str())
        .ok_or("missing requested_tier")?;
    let requested = car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(
        || {
            format!(
                "invalid requested_tier '{tier_str}' \
                 (expected read_only|sandbox_edit|full_access)"
            )
        },
    )?;

    // Gate + enforce against the daemon's SHARED durable ledger (C1),
    // ingesting only on a deploy verdict. Holding the ledger read-lock across
    // the engine ingest is safe: no handler holds the engine and
    // approval-ledger locks in the opposite order, so there is no inversion
    // to deadlock against.
    let ledger = state.approval_ledger.read().await;
    let out = {
        let mut engine = session.memgine.lock().await;
        engine.ingest_skill_governed(
            name,
            code,
            platform,
            trigger,
            description,
            supersedes,
            vec![],
            vec![],
            provenance,
            requested,
            &ledger,
        )
    };
    drop(ledger);

    let pending_json = out.enforcement.pending.as_ref().map(|p| {
        serde_json::json!({
            "fingerprint": p.fingerprint,
            "skill_name": p.skill_name,
            "requested_tier": requested.as_str(),
        })
    });
    let mut resp = serde_json::json!({
        "ingested": out.ingested.is_some(),
        "node": out.ingested.map(|n| n.index()),
        "decision": out.decision,
        "enforcement": out.enforcement,
    });
    if let Some(pj) = pending_json {
        resp.as_object_mut()
            .unwrap()
            .insert("pending_approval".into(), pj);
    }
    Ok(resp)
}

// ---------------------------------------------------------------------------
// Multi-agent coordination handlers
//
// The WsAgentRunner sends a `multi.run_agent` JSON-RPC request to the client.
// The client runs the model loop and responds with AgentOutput JSON.
// ---------------------------------------------------------------------------

/// AgentRunner backed by WebSocket callback to the client.
struct WsAgentRunner {
    channel: Arc<WsChannel>,
    host: Arc<crate::host::HostState>,
    client_id: String,
}

#[async_trait::async_trait]
impl car_multi::AgentRunner for WsAgentRunner {
    async fn run(
        &self,
        spec: &car_multi::AgentSpec,
        task: &str,
        _runtime: &car_engine::Runtime,
        _mailbox: &car_multi::Mailbox,
    ) -> std::result::Result<car_multi::AgentOutput, car_multi::MultiError> {
        use futures::SinkExt;

        let request_id = self.channel.next_request_id();
        let agent_id = agent_id_for_run(&self.client_id, &spec.name, &request_id);
        let agent = self
            .host
            .register_agent(
                &self.client_id,
                RegisterHostAgentRequest {
                    id: Some(agent_id.clone()),
                    name: spec.name.clone(),
                    kind: "callback".to_string(),
                    capabilities: spec.tools.clone(),
                    project: spec
                        .metadata
                        .get("project")
                        .and_then(|v| v.as_str())
                        .map(str::to_string),
                    pid: None,
                    display: serde_json::from_value(
                        spec.metadata
                            .get("display")
                            .cloned()
                            .unwrap_or(serde_json::Value::Null),
                    )
                    .unwrap_or_default(),
                    metadata: serde_json::to_value(&spec.metadata).unwrap_or(Value::Null),
                },
            )
            .await
            .map_err(|e| car_multi::MultiError::AgentFailed(spec.name.clone(), e))?;
        let _ = self
            .host
            .set_status(
                &self.client_id,
                SetHostAgentStatusRequest {
                    agent_id: agent.id.clone(),
                    status: HostAgentStatus::Running,
                    current_task: Some(task.to_string()),
                    message: Some(format!("{} started", spec.name)),
                    payload: serde_json::json!({ "task": task }),
                },
            )
            .await;

        let rpc_request = serde_json::json!({
            "jsonrpc": "2.0",
            "method": "multi.run_agent",
            "params": {
                "spec": spec,
                "task": task,
            },
            "id": request_id,
        });

        // Create oneshot channel for the response
        let (tx, rx) = tokio::sync::oneshot::channel();
        self.channel
            .pending
            .lock()
            .await
            .insert(request_id.clone(), tx);

        let msg = Message::Text(
            serde_json::to_string(&rpc_request)
                .map_err(|e| car_multi::MultiError::AgentFailed(spec.name.clone(), e.to_string()))?
                .into(),
        );
        if let Err(e) = self.channel.write.lock().await.send(msg).await {
            let _ = self
                .host
                .set_status(
                    &self.client_id,
                    SetHostAgentStatusRequest {
                        agent_id: agent_id.clone(),
                        status: HostAgentStatus::Errored,
                        current_task: None,
                        message: Some(format!("{} failed to start", spec.name)),
                        payload: serde_json::json!({ "error": e.to_string() }),
                    },
                )
                .await;
            return Err(car_multi::MultiError::AgentFailed(
                spec.name.clone(),
                format!("ws send error: {}", e),
            ));
        }

        // Wait for client response (5 min timeout for model loops)
        let response = match tokio::time::timeout(std::time::Duration::from_secs(300), rx).await {
            Ok(Ok(response)) => response,
            Ok(Err(_)) => {
                let _ = self
                    .host
                    .set_status(
                        &self.client_id,
                        SetHostAgentStatusRequest {
                            agent_id: agent_id.clone(),
                            status: HostAgentStatus::Errored,
                            current_task: None,
                            message: Some(format!("{} callback channel closed", spec.name)),
                            payload: Value::Null,
                        },
                    )
                    .await;
                return Err(car_multi::MultiError::AgentFailed(
                    spec.name.clone(),
                    "agent callback channel closed".into(),
                ));
            }
            Err(_) => {
                let _ = self
                    .host
                    .set_status(
                        &self.client_id,
                        SetHostAgentStatusRequest {
                            agent_id: agent_id.clone(),
                            status: HostAgentStatus::Errored,
                            current_task: None,
                            message: Some(format!("{} timed out", spec.name)),
                            payload: Value::Null,
                        },
                    )
                    .await;
                return Err(car_multi::MultiError::AgentFailed(
                    spec.name.clone(),
                    "agent callback timed out (300s)".into(),
                ));
            }
        };

        if let Some(err) = response.error {
            let _ = self
                .host
                .set_status(
                    &self.client_id,
                    SetHostAgentStatusRequest {
                        agent_id: agent_id.clone(),
                        status: HostAgentStatus::Errored,
                        current_task: None,
                        message: Some(format!("{} errored", spec.name)),
                        payload: serde_json::json!({ "error": err }),
                    },
                )
                .await;
            return Err(car_multi::MultiError::AgentFailed(spec.name.clone(), err));
        }

        let output_value = response.output.unwrap_or(Value::Null);
        let output: car_multi::AgentOutput = serde_json::from_value(output_value).map_err(|e| {
            car_multi::MultiError::AgentFailed(
                spec.name.clone(),
                format!("invalid AgentOutput: {}", e),
            )
        })?;
        let status = if output.error.is_some() {
            HostAgentStatus::Errored
        } else {
            HostAgentStatus::Completed
        };
        let message = if output.error.is_some() {
            format!("{} errored", spec.name)
        } else {
            format!("{} completed", spec.name)
        };
        let _ = self
            .host
            .set_status(
                &self.client_id,
                SetHostAgentStatusRequest {
                    agent_id,
                    status,
                    current_task: None,
                    message: Some(message),
                    payload: serde_json::to_value(&output).unwrap_or(Value::Null),
                },
            )
            .await;

        Ok(output)
    }
}

fn agent_id_for_run(client_id: &str, name: &str, request_id: &str) -> String {
    let safe_name: String = name
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '-'
            }
        })
        .collect();
    format!("{}:{}:{}", client_id, safe_name, request_id)
}

/// Build a [`car_multi::SharedInfra`], attaching a coordination budget when the
/// request carries a `budget` object (a `car_multi::BudgetLimits`, e.g.
/// `{"max_total_tokens": 200000, "max_agents": 12}`). Omitted fields are
/// unbounded; absent `budget` means no limits.
fn multi_infra_with_budget(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<car_multi::SharedInfra, String> {
    // Concurrency-anomaly gating (A5) is on for the daemon coordination path:
    // an isolated parallel swarm's cross-agent commit barrier is gated (stale
    // generation rejects the offending commit, reorder is serialized). The
    // infra SHARES the session runtime's state/log/policies (linus review
    // C-6): a fresh per-request store made `parent_keys_before` empty, so a
    // swarm write over a pre-existing session key could never be classified
    // as the read-modify-write that makes it a lost-update hazard — the
    // stale-generation check was structurally dead. Sharing also lands the
    // gate's audit events in the session log, matching the foreman path.
    let infra = car_multi::SharedInfra::with_shared(
        std::sync::Arc::clone(&session.runtime.state),
        std::sync::Arc::clone(&session.runtime.log),
        std::sync::Arc::clone(&session.runtime.policies),
    )
    .with_concurrency_gating();
    match req.params.get("budget") {
        None | Some(Value::Null) => Ok(infra),
        Some(v) => {
            let limits: car_multi::BudgetLimits =
                serde_json::from_value(v.clone()).map_err(|e| format!("invalid budget: {}", e))?;
            Ok(infra.with_budget(limits))
        }
    }
}

async fn handle_multi_swarm(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let mode_str = req
        .params
        .get("mode")
        .and_then(|v| v.as_str())
        .ok_or("missing 'mode'")?;
    let agents_val = req.params.get("agents").ok_or("missing 'agents'")?;
    let task = req
        .params
        .get("task")
        .and_then(|v| v.as_str())
        .ok_or("missing 'task'")?;

    let swarm_mode: car_multi::SwarmMode = serde_json::from_str(&format!("\"{}\"", mode_str))
        .map_err(|e| format!("invalid mode '{}': {}", mode_str, e))?;
    let agent_specs: Vec<car_multi::AgentSpec> =
        serde_json::from_value(agents_val.clone()).map_err(|e| format!("invalid agents: {}", e))?;
    let synth: Option<car_multi::AgentSpec> = req
        .params
        .get("synthesizer")
        .map(|v| serde_json::from_value(v.clone()))
        .transpose()
        .map_err(|e| format!("invalid synthesizer: {}", e))?;

    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
        channel: session.channel.clone(),
        host: session.host.clone(),
        client_id: session.client_id.clone(),
    });
    let infra = multi_infra_with_budget(req, session)?;

    let mut swarm = car_multi::Swarm::new(agent_specs, swarm_mode);
    if let Some(s) = synth {
        swarm = swarm.with_synthesizer(s);
    }

    let result = swarm
        .run(task, &runner, &infra)
        .await
        .map_err(|e| format!("swarm error: {}", e))?;
    serde_json::to_value(result).map_err(|e| e.to_string())
}

async fn handle_multi_pipeline(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let stages_val = req.params.get("stages").ok_or("missing 'stages'")?;
    let task = req
        .params
        .get("task")
        .and_then(|v| v.as_str())
        .ok_or("missing 'task'")?;

    let stage_specs: Vec<car_multi::AgentSpec> =
        serde_json::from_value(stages_val.clone()).map_err(|e| format!("invalid stages: {}", e))?;

    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
        channel: session.channel.clone(),
        host: session.host.clone(),
        client_id: session.client_id.clone(),
    });
    let infra = multi_infra_with_budget(req, session)?;

    let result = car_multi::Pipeline::new(stage_specs)
        .run(task, &runner, &infra)
        .await
        .map_err(|e| format!("pipeline error: {}", e))?;
    serde_json::to_value(result).map_err(|e| e.to_string())
}

async fn handle_multi_supervisor(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let workers_val = req.params.get("workers").ok_or("missing 'workers'")?;
    let supervisor_val = req.params.get("supervisor").ok_or("missing 'supervisor'")?;
    let task = req
        .params
        .get("task")
        .and_then(|v| v.as_str())
        .ok_or("missing 'task'")?;
    let max_rounds = req
        .params
        .get("max_rounds")
        .and_then(|v| v.as_u64())
        .unwrap_or(3) as u32;

    let worker_specs: Vec<car_multi::AgentSpec> = serde_json::from_value(workers_val.clone())
        .map_err(|e| format!("invalid workers: {}", e))?;
    let supervisor_spec: car_multi::AgentSpec = serde_json::from_value(supervisor_val.clone())
        .map_err(|e| format!("invalid supervisor: {}", e))?;

    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
        channel: session.channel.clone(),
        host: session.host.clone(),
        client_id: session.client_id.clone(),
    });
    let infra = multi_infra_with_budget(req, session)?;

    let result = car_multi::Supervisor::new(worker_specs, supervisor_spec)
        .with_max_rounds(max_rounds)
        .run(task, &runner, &infra)
        .await
        .map_err(|e| format!("supervisor error: {}", e))?;
    serde_json::to_value(result).map_err(|e| e.to_string())
}

async fn handle_multi_map_reduce(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let mapper_val = req.params.get("mapper").ok_or("missing 'mapper'")?;
    let reducer_val = req.params.get("reducer").ok_or("missing 'reducer'")?;
    let task = req
        .params
        .get("task")
        .and_then(|v| v.as_str())
        .ok_or("missing 'task'")?;
    let items_val = req.params.get("items").ok_or("missing 'items'")?;

    let mapper_spec: car_multi::AgentSpec =
        serde_json::from_value(mapper_val.clone()).map_err(|e| format!("invalid mapper: {}", e))?;
    let reducer_spec: car_multi::AgentSpec = serde_json::from_value(reducer_val.clone())
        .map_err(|e| format!("invalid reducer: {}", e))?;
    let items: Vec<String> =
        serde_json::from_value(items_val.clone()).map_err(|e| format!("invalid items: {}", e))?;

    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
        channel: session.channel.clone(),
        host: session.host.clone(),
        client_id: session.client_id.clone(),
    });
    let infra = multi_infra_with_budget(req, session)?;

    let result = car_multi::MapReduce::new(mapper_spec, reducer_spec)
        .run(task, &items, &runner, &infra)
        .await
        .map_err(|e| format!("map_reduce error: {}", e))?;
    serde_json::to_value(result).map_err(|e| e.to_string())
}

async fn handle_multi_vote(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let agents_val = req.params.get("agents").ok_or("missing 'agents'")?;
    let task = req
        .params
        .get("task")
        .and_then(|v| v.as_str())
        .ok_or("missing 'task'")?;

    let agent_specs: Vec<car_multi::AgentSpec> =
        serde_json::from_value(agents_val.clone()).map_err(|e| format!("invalid agents: {}", e))?;
    let synth: Option<car_multi::AgentSpec> = req
        .params
        .get("synthesizer")
        .map(|v| serde_json::from_value(v.clone()))
        .transpose()
        .map_err(|e| format!("invalid synthesizer: {}", e))?;

    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
        channel: session.channel.clone(),
        host: session.host.clone(),
        client_id: session.client_id.clone(),
    });
    let infra = multi_infra_with_budget(req, session)?;

    let mut vote = car_multi::Vote::new(agent_specs);
    if let Some(s) = synth {
        vote = vote.with_synthesizer(s);
    }

    let result = vote
        .run(task, &runner, &infra)
        .await
        .map_err(|e| format!("vote error: {}", e))?;
    serde_json::to_value(result).map_err(|e| e.to_string())
}

async fn handle_multi_tournament(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let competitors_val = req
        .params
        .get("competitors")
        .ok_or("missing 'competitors'")?;
    let judge_val = req.params.get("judge").ok_or("missing 'judge'")?;
    let task = req
        .params
        .get("task")
        .and_then(|v| v.as_str())
        .ok_or("missing 'task'")?;

    let competitors: Vec<car_multi::AgentSpec> = serde_json::from_value(competitors_val.clone())
        .map_err(|e| format!("invalid competitors: {}", e))?;
    let judge: car_multi::AgentSpec =
        serde_json::from_value(judge_val.clone()).map_err(|e| format!("invalid judge: {}", e))?;

    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
        channel: session.channel.clone(),
        host: session.host.clone(),
        client_id: session.client_id.clone(),
    });
    let infra = multi_infra_with_budget(req, session)?;

    let result = car_multi::Tournament::new(competitors, judge)
        .run(task, &runner, &infra)
        .await
        .map_err(|e| format!("tournament error: {}", e))?;
    serde_json::to_value(result).map_err(|e| e.to_string())
}

async fn handle_multi_subtask(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let main_val = req.params.get("main").ok_or("missing 'main'")?;
    let task = req
        .params
        .get("task")
        .and_then(|v| v.as_str())
        .ok_or("missing 'task'")?;

    let main_spec: car_multi::AgentSpec =
        serde_json::from_value(main_val.clone()).map_err(|e| format!("invalid main: {}", e))?;

    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
        channel: session.channel.clone(),
        host: session.host.clone(),
        client_id: session.client_id.clone(),
    });
    let infra = multi_infra_with_budget(req, session)?;

    let result = car_multi::SpawnSubtask::new(main_spec)
        .run(task, &runner, &infra)
        .await
        .map_err(|e| format!("spawn_subtask error: {}", e))?;
    serde_json::to_value(result).map_err(|e| e.to_string())
}

// ---------------------------------------------------------------------------
// Scheduler handlers
// ---------------------------------------------------------------------------

fn handle_scheduler_create(req: &JsonRpcMessage) -> Result<Value, String> {
    let name = req
        .params
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or("scheduler.create requires 'name'")?;
    let prompt = req
        .params
        .get("prompt")
        .and_then(|v| v.as_str())
        .ok_or("scheduler.create requires 'prompt'")?;

    let mut task = car_scheduler::Task::new(name, prompt);

    if let Some(t) = req.params.get("trigger").and_then(|v| v.as_str()) {
        let trigger = match t {
            "once" => car_scheduler::TaskTrigger::Once,
            "cron" => car_scheduler::TaskTrigger::Cron,
            "interval" => car_scheduler::TaskTrigger::Interval,
            "file_watch" => car_scheduler::TaskTrigger::FileWatch,
            _ => car_scheduler::TaskTrigger::Manual,
        };
        let schedule = req
            .params
            .get("schedule")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        task = task.with_trigger(trigger, schedule);
    }

    if let Some(sp) = req.params.get("system_prompt").and_then(|v| v.as_str()) {
        task = task.with_system_prompt(sp);
    }

    serde_json::to_value(&task).map_err(|e| e.to_string())
}

/// Common param extraction for the OS-schedule handlers: `{ task, program, args? }`.
fn os_schedule_params(req: &JsonRpcMessage) -> Result<(String, String, String), String> {
    let task = req
        .params
        .get("task")
        .ok_or("requires 'task'")
        .and_then(|v| serde_json::to_string(v).map_err(|_| "invalid 'task'"))?;
    let program = req
        .params
        .get("program")
        .and_then(|v| v.as_str())
        .ok_or("requires 'program' (the binary the OS runs to execute the task)")?
        .to_string();
    let args = req
        .params
        .get("args")
        .cloned()
        .unwrap_or_else(|| Value::Array(vec![]));
    let args_json = serde_json::to_string(&args).map_err(|e| e.to_string())?;
    Ok((task, program, args_json))
}

/// Preview the durable OS-level schedule a task would install (no I/O).
fn handle_scheduler_os_render(req: &JsonRpcMessage) -> Result<Value, String> {
    let (task, program, args) = os_schedule_params(req)?;
    let json = car_ffi_common::scheduler::render_os_schedule(&task, &program, &args)?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

/// Install a durable OS-level schedule (launchd/cron) for a task so it fires
/// even when the daemon is down.
fn handle_scheduler_os_install(req: &JsonRpcMessage) -> Result<Value, String> {
    let (task, program, args) = os_schedule_params(req)?;
    let json = car_ffi_common::scheduler::install_os_schedule(&task, &program, &args)?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

/// Remove a task's OS-level schedule. Params: `{ label }` (full label or bare id).
fn handle_scheduler_os_uninstall(req: &JsonRpcMessage) -> Result<Value, String> {
    let label = req
        .params
        .get("label")
        .and_then(|v| v.as_str())
        .ok_or("scheduler.os_uninstall requires 'label' (full label or task id)")?;
    let json = car_ffi_common::scheduler::uninstall_os_schedule(label)?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

/// List CAR-managed OS-level schedule labels installed on this host.
fn handle_scheduler_os_list(_req: &JsonRpcMessage) -> Result<Value, String> {
    let json = car_ffi_common::scheduler::list_os_schedules()?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

/// Reap orphaned OS-level schedules whose backing task was deleted or made
/// non-schedulable. Returns `{ removed, kept, errors }`.
fn handle_scheduler_os_reconcile(_req: &JsonRpcMessage) -> Result<Value, String> {
    let json = car_ffi_common::scheduler::reconcile_os_schedules()?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

/// Reap orphaned OS-level schedules at daemon boot — best-effort, mirrors
/// [`recover_workflow_checkpoints`]. A schedule whose task was deleted while the
/// daemon was down keeps firing a no-op command until this runs.
pub fn reconcile_os_schedules_at_boot() {
    match car_ffi_common::scheduler::reconcile_os_schedules() {
        Ok(json) => match serde_json::from_str::<Value>(&json) {
            Ok(report) => {
                let removed = report
                    .get("removed")
                    .and_then(Value::as_array)
                    .map_or(0, Vec::len);
                let errors = report
                    .get("errors")
                    .and_then(Value::as_array)
                    .map_or(0, Vec::len);
                if removed > 0 || errors > 0 {
                    tracing::info!(removed, errors, "reaped orphaned OS schedules at boot");
                }
            }
            Err(e) => {
                tracing::warn!(error = %e, "OS-schedule reconcile returned unparseable report")
            }
        },
        Err(e) => tracing::warn!(error = %e, "OS-schedule reconcile failed at boot"),
    }
}

/// Seed a client-supplied task with any prior execution history persisted under
/// the given TaskStore. The WS `scheduler.run` / `scheduler.run_loop` surface is
/// otherwise stateless — a client deserializes a `Task` and posts it each call,
/// so `task.executions` arrives empty and the deterministic occurrence guard in
/// [`car_scheduler::Executor::run_occurrence`] never sees the prior run. Loading
/// the stored task by id and copying its `executions` (plus the run bookkeeping
/// the guard reads alongside them) restores statefulness across the JSON-RPC
/// boundary, so a replayed Interval/Once/Cron occurrence dedups. Manual triggers
/// carry no occurrence id and are intentionally never deduped — seeding is a
/// no-op for them.
fn seed_task_from_store(task: &mut car_scheduler::Task, store: &car_scheduler::TaskStore) {
    if let Some(prior) = store.load(&task.id) {
        // Anchor the occurrence slot on the persisted creation time and copy the
        // prior execution history so the admission guard can recognize a replay.
        task.created_at = prior.created_at;
        task.executions = prior.executions;
        task.run_count = prior.run_count;
        task.last_run_at = prior.last_run_at;
    }
}

/// Run a single task occurrence statefully against a TaskStore, persisting the
/// mutated task so the next call sees this run. Shared by the WS handler and the
/// dedup tests.
async fn run_scheduler_task_once(
    task: &mut car_scheduler::Task,
    runner: Arc<dyn car_multi::AgentRunner>,
    store: &car_scheduler::TaskStore,
) -> car_scheduler::TaskExecution {
    seed_task_from_store(task, store);
    let executor = car_scheduler::Executor::new(runner);
    let execution = executor.run_once(task).await;
    let _ = store.save(task);
    execution
}

/// Run a task loop statefully against a TaskStore, persisting the mutated task
/// afterward. Shared by the WS handler and the dedup tests.
async fn run_scheduler_task_loop(
    task: &mut car_scheduler::Task,
    max_iterations: Option<u32>,
    runner: Arc<dyn car_multi::AgentRunner>,
    store: &car_scheduler::TaskStore,
) -> Vec<car_scheduler::TaskExecution> {
    seed_task_from_store(task, store);
    let executor = car_scheduler::Executor::new(runner);
    let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
    let executions = executor.run_loop(task, max_iterations, cancel_rx).await;
    let _ = store.save(task);
    executions
}

async fn handle_scheduler_run(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let task_val = req
        .params
        .get("task")
        .ok_or("scheduler.run requires 'task'")?;
    let mut task: car_scheduler::Task =
        serde_json::from_value(task_val.clone()).map_err(|e| format!("invalid task: {}", e))?;

    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
        channel: session.channel.clone(),
        host: session.host.clone(),
        client_id: session.client_id.clone(),
    });
    let store = car_scheduler::TaskStore::new(&car_scheduler::TaskStore::default_path());
    let execution = run_scheduler_task_once(&mut task, runner, &store).await;

    serde_json::to_value(&execution).map_err(|e| e.to_string())
}

async fn handle_scheduler_run_loop(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let task_val = req
        .params
        .get("task")
        .ok_or("scheduler.run_loop requires 'task'")?;
    let mut task: car_scheduler::Task =
        serde_json::from_value(task_val.clone()).map_err(|e| format!("invalid task: {}", e))?;
    let max_iterations = req
        .params
        .get("max_iterations")
        .and_then(|v| v.as_u64())
        .map(|v| v as u32);

    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
        channel: session.channel.clone(),
        host: session.host.clone(),
        client_id: session.client_id.clone(),
    });
    let store = car_scheduler::TaskStore::new(&car_scheduler::TaskStore::default_path());
    let executions = run_scheduler_task_loop(&mut task, max_iterations, runner, &store).await;

    serde_json::to_value(&executions).map_err(|e| e.to_string())
}

// ---------------------------------------------------------------------------
// Inference handlers
// ---------------------------------------------------------------------------

pub(crate) fn get_inference_engine(state: &ServerState) -> &Arc<car_inference::InferenceEngine> {
    state.inference.get_or_init(|| {
        let engine = Arc::new(car_inference::InferenceEngine::new(
            car_inference::InferenceConfig::default(),
        ));
        // Phase E2: keep the model catalog current automatically. OPT-IN via
        // `CAR_MODEL_DISCOVERY=1` (off by default — it makes an outbound
        // provider call to `/v1/models` on a timer, which not every operator
        // wants). When enabled, run provider model discovery on first engine
        // init then re-run daily, caching newly-released chat/reasoning models
        // (e.g. a new gpt-5.x) as Community entries that load at the next
        // daemon start — so the router picks them up without a release.
        // Best-effort: no key / no provider is a no-op.
        let discovery_enabled = std::env::var("CAR_MODEL_DISCOVERY")
            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
            .unwrap_or(false);
        if discovery_enabled {
            // `get_or_init` can run outside a tokio runtime (a sync or
            // non-async first caller); `tokio::spawn` panics there. Only spawn
            // when a runtime is actually present, otherwise log and skip.
            match tokio::runtime::Handle::try_current() {
                Ok(handle) => {
                    let disco = Arc::clone(&engine);
                    handle.spawn(async move {
                        loop {
                            match disco.discover_models().await {
                                Ok(n) => info!("model discovery: {n} discovered models cached"),
                                Err(e) => tracing::debug!("model discovery skipped: {e}"),
                            }
                            tokio::time::sleep(std::time::Duration::from_secs(24 * 3600)).await;
                        }
                    });
                }
                Err(_) => tracing::debug!(
                    "model discovery enabled (CAR_MODEL_DISCOVERY) but no tokio runtime at \
                     engine init — skipping the background task"
                ),
            }
        }
        engine
    })
}

/// `foreman.plan` — decompose a coding `goal` into a footprint-annotated,
/// scheduled subtask plan. The planner's repair loop runs against the daemon's
/// inference engine. Returns a [`car_multi::PlanReport`] (the SCHEDULER surface;
/// the gate verdict is a separate surface produced by `foreman.run`).
async fn handle_foreman_plan(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let goal = msg
        .params
        .get("goal")
        .and_then(|v| v.as_str())
        .ok_or("missing 'goal'")?
        .to_string();
    let repo = msg
        .params
        .get("repo")
        .and_then(|v| v.as_str())
        .map(std::path::PathBuf::from)
        .or_else(|| std::env::current_dir().ok())
        .ok_or("no 'repo' and cwd is unavailable")?;
    let max_attempts = msg
        .params
        .get("max_attempts")
        .and_then(|v| v.as_u64())
        .unwrap_or(3) as u32;

    let engine = std::sync::Arc::clone(get_inference_engine(state));
    let result = car_multi::decompose(&repo, &goal, max_attempts, move |prompt| {
        let engine = std::sync::Arc::clone(&engine);
        async move {
            engine
                .generate(car_inference::GenerateRequest {
                    prompt,
                    ..Default::default()
                })
                .await
                .map_err(|e| e.to_string())
        }
    })
    .await;

    serde_json::to_value(car_multi::PlanReport::from(&result))
        .map_err(|e| format!("serialize plan: {e}"))
}

/// `foreman.run` — plan a coding `goal`, farm the independent subtasks to an
/// external coding CLI in isolated git worktrees, gate each worktree and the
/// integrated union. Returns `{ plan, ran, run? }`. **Spends real agent quota.**
async fn handle_foreman_run(
    msg: &JsonRpcMessage,
    state: &ServerState,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let goal = msg
        .params
        .get("goal")
        .and_then(|v| v.as_str())
        .ok_or("missing 'goal'")?
        .to_string();
    let repo = msg
        .params
        .get("repo")
        .and_then(|v| v.as_str())
        .map(std::path::PathBuf::from)
        .or_else(|| std::env::current_dir().ok())
        .ok_or("no 'repo' and cwd is unavailable")?;
    let max_attempts = msg
        .params
        .get("max_attempts")
        .and_then(|v| v.as_u64())
        .unwrap_or(3) as u32;
    let adapter = msg
        .params
        .get("adapter")
        .and_then(|v| v.as_str())
        .unwrap_or("claude-code")
        .to_string();
    let str_array = |key: &str| -> Option<Vec<String>> {
        msg.params.get(key).and_then(|v| v.as_array()).map(|a| {
            a.iter()
                .filter_map(|x| x.as_str().map(String::from))
                .collect()
        })
    };
    // Per-worktree regression gate, and the integrated-union goal gate. A subtask
    // does only part of the goal, so a goal-level test belongs on the union, not
    // per-worktree; `union_verify_command` falls back to `verify_command`.
    let verify_command = str_array("verify_command");
    let union_verify_command = str_array("union_verify_command");

    // Run the full pipeline: decompose, then farm out + verify the union, OR —
    // when the plan doesn't decompose (invalid, or no parallelism worth it) —
    // fall back to a single whole-goal session. Always produces a result.
    let engine = std::sync::Arc::clone(get_inference_engine(state));
    let agent = car_external_agents::ForemanExternalAgent::new(adapter);
    // Reuse the session's runtime policies + event log so the merge-verify gate
    // consults the operator's `policy.register`'d rules (it can deny a merge) and
    // its GateAccepted/GateRejected events are audited in the session log —
    // instead of a fresh, empty engine.
    let infra = car_multi::SharedInfra::with_shared(
        std::sync::Arc::clone(&session.runtime.state),
        std::sync::Arc::clone(&session.runtime.log),
        std::sync::Arc::clone(&session.runtime.policies),
    );
    let config = car_multi::FarmOutConfig {
        verify_command,
        union_verify_command,
        // The daemon entrypoint is delivery-first: recover via a single session
        // if the parallel union fails, rather than hand back a non-delivery.
        recover_via_single_session: true,
        ..Default::default()
    };
    let outcome = car_multi::run_foreman(
        &repo,
        &goal,
        max_attempts,
        &agent,
        &config,
        &infra,
        move |prompt| {
            let engine = std::sync::Arc::clone(&engine);
            async move {
                engine
                    .generate(car_inference::GenerateRequest {
                        prompt,
                        ..Default::default()
                    })
                    .await
                    .map_err(|e| e.to_string())
            }
        },
    )
    .await;

    let mode = match outcome.mode {
        car_multi::RunMode::Parallel => "parallel",
        car_multi::RunMode::SingleSession => "single_session",
        car_multi::RunMode::ParallelThenSingleSession => "parallel_then_single_session",
        car_multi::RunMode::RegionalReplan => "regional_replan",
    };
    let report =
        car_multi::ForemanReport::from_run(&outcome.outcomes, outcome.integration.as_ref());
    Ok(serde_json::json!({
        "plan": car_multi::PlanReport::from(&outcome.plan),
        "mode": mode,
        "ran": true,
        "delivered": outcome.delivered(),
        "run": report,
    }))
}

/// Decrements a session's in-flight chat-infer counter on drop, so the
/// conversation-outcome concurrency guard (neo #1) is released on every return
/// path — including an early error from generation.
struct ChatInflightGuard<'a>(&'a std::sync::atomic::AtomicUsize);
impl Drop for ChatInflightGuard<'_> {
    fn drop(&mut self) {
        self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
    }
}

/// Capture the conversation-outcome inputs for a (possibly) chat infer, shared
/// by the streaming and non-streaming `infer` paths so the gate + idle-guard
/// rules can't drift apart. Must be called BEFORE `req` is consumed by
/// generation. Returns `(chat_user_text, was_idle, guard)`:
/// - `chat_user_text` is `Some` only for an EXPLICITLY-declared chat turn
///   (`intent.task == Chat`) — "no tools" means "not an agent loop", not "is a
///   conversation", so a one-off summarize/classify infer never feeds the
///   signal (neo #2).
/// - `was_idle` is true only when this turn BEGAN while the session was idle;
///   recording a turn against a concurrent infer's user text would fabricate
///   adjacency and mislabel routing stats (neo #1).
/// - `guard` decrements the in-flight counter on drop; the caller must hold it
///   for the whole generation.
fn begin_chat_turn<'a>(
    req: &car_inference::GenerateRequest,
    session: &'a crate::session::ClientSession,
) -> (Option<String>, bool, Option<ChatInflightGuard<'a>>) {
    let is_chat = req
        .intent
        .as_ref()
        .is_some_and(|i| i.task == Some(car_inference::TaskHint::Chat));
    if !is_chat {
        return (None, false, None);
    }
    let chat_user_text = req
        .messages
        .as_ref()
        .and_then(|ms| {
            ms.iter().rev().find_map(|m| match m {
                car_inference::Message::User { content } => Some(content.clone()),
                _ => None,
            })
        })
        .or_else(|| {
            let p = req.prompt.trim();
            (!p.is_empty()).then(|| p.to_string())
        });
    let was_idle = session
        .chat_inflight
        .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
        == 0;
    (
        chat_user_text,
        was_idle,
        Some(ChatInflightGuard(&session.chat_inflight)),
    )
}

/// Score the session's prior chat turn (`slot`) against `new_user_text` and feed
/// the tracker, then overwrite `slot` with this turn. Pure over the borrowed
/// state (no locks/I/O of its own) so it's unit-testable against a real
/// `OutcomeTracker`. trace_id/model are carried from the `InferenceResult` that
/// produced each turn — credit lands on the exact model that generated the turn.
fn score_and_remember_chat_turn(
    slot: &mut Option<crate::session::LastChatTurn>,
    tracker: &mut car_inference::OutcomeTracker,
    new_user_text: String,
    result_text: &str,
    trace_id: &str,
    model_id: &str,
) -> car_memgine::outcome_bridge::CreditTally {
    use car_memgine::outcome_signal::ConversationTurn;
    let mut tally = car_memgine::outcome_bridge::CreditTally::default();
    if let Some(prev) = slot.as_ref() {
        let turns = vec![
            ConversationTurn::user(prev.user_text.clone(), "u_prev"),
            ConversationTurn::assistant(
                prev.assistant_text.clone(),
                "a_prev",
                Some(prev.model_id.clone()),
                Some(prev.trace_id.clone()),
            ),
            ConversationTurn::user(new_user_text.clone(), "u_new"),
        ];
        tally = car_memgine::outcome_bridge::record_conversation_outcomes(tracker, &turns);
    }
    *slot = Some(crate::session::LastChatTurn {
        user_text: new_user_text,
        assistant_text: result_text.to_string(),
        trace_id: trace_id.to_string(),
        model_id: model_id.to_string(),
    });
    tally
}

#[cfg(test)]
mod chat_outcome_tests {
    use super::score_and_remember_chat_turn;
    use crate::session::LastChatTurn;
    use car_inference::{InferenceTask, OutcomeTracker};

    #[test]
    fn first_turn_records_nothing_and_stashes() {
        let mut slot: Option<LastChatTurn> = None;
        let mut tracker = OutcomeTracker::new();
        let tally = score_and_remember_chat_turn(
            &mut slot,
            &mut tracker,
            "hello".into(),
            "hi there",
            "t1",
            "m1",
        );
        assert_eq!(tally.submitted, 0, "no prior turn → nothing to score");
        let stashed = slot.expect("this turn must be remembered");
        assert_eq!(stashed.trace_id, "t1");
        assert_eq!(stashed.user_text, "hello");
    }

    #[test]
    fn second_turn_scores_prior_turn_and_reverses_mechanical_success() {
        let mut tracker = OutcomeTracker::new();
        // Prior assistant turn: trace t1 / model m1, completed with output →
        // mechanical success booked.
        let tid = tracker.record_start("m1", InferenceTask::Generate, "test");
        tracker.record_complete(&tid, 10, 1, 2);
        assert_eq!(tracker.profile("m1").unwrap().success_count, 1);

        let mut slot = Some(LastChatTurn {
            user_text: "convert this to async".into(),
            assistant_text: "here's a threaded version".into(),
            trace_id: tid.clone(),
            model_id: "m1".into(),
        });
        // The next user turn circles (repair marker) → the prior turn is a failure.
        let tally = score_and_remember_chat_turn(
            &mut slot,
            &mut tracker,
            "no, that's not what i asked".into(),
            "async version",
            "t2",
            "m1",
        );
        assert_eq!(
            tally.resolved, 1,
            "prior turn's trace was pending → resolved"
        );
        let p = tracker.profile("m1").unwrap();
        assert_eq!(p.success_count, 0, "mechanical success reversed");
        assert_eq!(p.fail_count, 1, "circle booked as a failure for m1");
        assert_eq!(slot.unwrap().trace_id, "t2", "this turn is now remembered");
    }
}

async fn handle_infer(
    msg: &JsonRpcMessage,
    state: &ServerState,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    use futures::SinkExt;
    use std::time::Duration;

    let engine = get_inference_engine(state);
    let mut req: car_inference::GenerateRequest =
        serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {}", e))?;

    // If context_query is provided, build context from memgine and inject it
    if let Some(cq) = msg.params.get("context_query").and_then(|v| v.as_str()) {
        let mut memgine = session.memgine.lock().await;
        // Split at the stable Identity+Constraints boundary so an Anthropic
        // cached request breaks after the stable prefix (which hits) instead of
        // over the whole churning context. `full` is byte-identical to the old
        // `build_context`, so non-Anthropic providers and StateBench see the
        // same context; the prefix is just a hint the handler validates.
        let split = memgine.build_context_split_for_model(cq, None);
        if !split.full.is_empty() {
            req.context_stable_prefix = split.stable_prefix();
            req.context = Some(split.full);
        }
    }

    // Stakes-aware routing: a session authorized for FullAccess (irreversible /
    // externally-consequential) actions routes its inference quality-first — we
    // never economize on a session that can take actions it can't take back. The
    // policy→intent mapping lives here (car-server-core has both the session
    // tier and the request); car-inference stays policy-free.
    if session.permission_gate.read().await.granted_tier() == car_policy::PermissionTier::FullAccess
    {
        req.intent.get_or_insert_with(Default::default).high_stakes = true;
    }

    // Conversation-outcome (Part B): capture the chat user turn + idle-guard
    // verdict before `req` is consumed by generate_tracked. Shared with the
    // streaming path via `begin_chat_turn` (gate + concurrency rules live there).
    let (chat_user_text, chat_was_idle, _chat_inflight) = begin_chat_turn(&req, session);

    // Heartbeat (car#476). A cold local model-load, a queued admission slot, or
    // a slow remote can each keep this handler busy well past the FFI client's
    // old flat 30s read window. Emit an `infer.progress` notification carrying
    // this request id every INFER_HEARTBEAT_INTERVAL for the whole in-flight
    // span, so the client holds an *idle* read deadline (reset per heartbeat)
    // and only reaps true daemon silence. Model-agnostic on purpose: a local
    // cold-start and a slow remote are both covered without the client having
    // to know which model the router picked. The guard aborts the ticker the
    // instant this handler returns (any path); an older client that doesn't
    // idle-reset simply drops the unknown notification.
    const INFER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
    struct HeartbeatGuard(tokio::task::JoinHandle<()>);
    impl Drop for HeartbeatGuard {
        fn drop(&mut self) {
            self.0.abort();
        }
    }
    let _heartbeat = {
        let channel = session.channel.clone();
        let request_id = msg.id.clone();
        HeartbeatGuard(tokio::spawn(async move {
            let mut ticker = tokio::time::interval(INFER_HEARTBEAT_INTERVAL);
            ticker.tick().await; // consume the immediate first tick (fires at t=0)
            loop {
                ticker.tick().await;
                let notif = serde_json::json!({
                    "jsonrpc": "2.0",
                    "method": "infer.progress",
                    "params": { "id": request_id },
                });
                let Ok(text) = serde_json::to_string(&notif) else {
                    break;
                };
                if channel
                    .write
                    .lock()
                    .await
                    .send(Message::Text(text.into()))
                    .await
                    .is_err()
                {
                    break; // connection gone — stop heartbeating
                }
            }
        }))
    };

    // Process-wide admission gate. Held for the duration of the
    // generation so a burst of concurrent infer RPCs can't multiply
    // KV-cache + activation memory and take the host out. The
    // `_permit` binding is intentional — its `Drop` releases the slot
    // when this future returns.
    let _permit = state.admission.acquire().await;

    // Use generate_tracked() so tool_calls, usage, model_used, trace_id, and
    // latency_ms are preserved in the response. Plain `generate()` discards
    // everything except `.text`, which silently breaks tool-use over the
    // WebSocket protocol (issue #43).
    //
    // NOTE: This directly serializes `InferenceResult`. Any field added to
    // that struct in `car-inference` becomes part of the public WebSocket
    // protocol. The shape is locked by `inference_result_serializes_*` tests
    // in car-inference; updating those tests is part of intentionally
    // changing the wire contract.
    let result = engine
        .generate_tracked(req)
        .await
        .map_err(|e| e.to_string())?;

    // Conversation-outcome (Part B): score the session's PRIOR assistant turn by
    // this new user turn (the next-turn-is-the-label signal), then remember this
    // turn for the next round. trace_id/model are carried straight from the
    // result that produced each turn — never reconstructed from order — so credit
    // lands on the exact model that generated the judged turn. Gated on
    // `chat_was_idle` so overlapping infers don't fabricate adjacency (neo #1).
    // Best-effort: never fails the inference response.
    if let Some(user_text) = chat_user_text.filter(|_| chat_was_idle) {
        let mut slot = session.last_chat_turn.lock().await;
        let tracker_arc = engine.outcome_tracker();
        let mut tracker = tracker_arc.write().await;
        let tally = score_and_remember_chat_turn(
            &mut slot,
            &mut tracker,
            user_text,
            &result.text,
            &result.trace_id,
            &result.model_used,
        );
        if tally.submitted > 0 {
            tracing::debug!(
                submitted = tally.submitted,
                resolved = tally.resolved,
                "conversation-outcome recorded for prior chat turn"
            );
        }
    }

    serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
}

/// Streaming inference — mirrors NAPI `inferStream`. Closes
/// Parslee-ai/car-releases#30. Same `GenerateRequest` shape as
/// `infer`; emits `inference.stream.event` JSON-RPC notifications
/// during the run, and returns the final `InferenceResult` as the
/// JSON-RPC response when the stream completes.
///
/// Notification shape (server → client):
/// ```jsonc
/// {
///   "jsonrpc": "2.0",
///   "method": "inference.stream.event",
///   "params": {
///     "request_id": "<original RPC id>",
///     "event": { "type": "text" | "tool_start" | "tool_delta" | "usage", ... }
///   }
/// }
/// ```
///
/// The final `done` event is not pushed as a notification — it's
/// the JSON-RPC response with the accumulated `InferenceResult`.
/// `video.generate` — daemon-side wrapper for
/// `InferenceEngine::generate_video`. Mirrors `handle_infer`'s
/// admission gate + JSON request shape (Parslee-ai/car#185).
///
/// Previously the CLI's `cmd_video` constructed an in-process
/// engine and called `generate_video` directly — a v0.7 holdover
/// that bypassed the daemon. With this handler the CLI proxies
/// here, so the engine-level audio_passthrough gate fires
/// inside the daemon process where all FFI surfaces converge.
async fn handle_image_generate(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let req: car_inference::GenerateImageRequest =
        serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {}", e))?;
    // Share the same admission gate as text/video generation — a burst
    // of image requests shouldn't smuggle around the concurrency cap.
    let _permit = state.admission.acquire().await;
    let result = engine
        .generate_image(req)
        .await
        .map_err(|e| e.to_string())?;
    serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
}

async fn handle_video_generate(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let req: car_inference::GenerateVideoRequest =
        serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {}", e))?;
    let _permit = state.admission.acquire().await;
    let result = engine
        .generate_video(req)
        .await
        .map_err(|e| e.to_string())?;
    serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
}

async fn handle_infer_stream(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &ServerState,
) -> Result<Value, String> {
    use futures::SinkExt;
    use tokio_tungstenite::tungstenite::Message;

    let engine = get_inference_engine(state);
    let mut req: car_inference::GenerateRequest =
        serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {}", e))?;

    // Same context-injection convenience as non-streaming `infer` so
    // the two methods have parity on the call shape.
    if let Some(cq) = msg.params.get("context_query").and_then(|v| v.as_str()) {
        let mut memgine = session.memgine.lock().await;
        // Split at the stable Identity+Constraints boundary so an Anthropic
        // cached request breaks after the stable prefix (which hits) instead of
        // over the whole churning context. `full` is byte-identical to the old
        // `build_context`, so non-Anthropic providers and StateBench see the
        // same context; the prefix is just a hint the handler validates.
        let split = memgine.build_context_split_for_model(cq, None);
        if !split.full.is_empty() {
            req.context_stable_prefix = split.stable_prefix();
            req.context = Some(split.full);
        }
    }

    // Stakes-aware routing (parity with non-streaming `infer`): a FullAccess
    // session routes inference quality-first.
    if session.permission_gate.read().await.granted_tier() == car_policy::PermissionTier::FullAccess
    {
        req.intent.get_or_insert_with(Default::default).high_stakes = true;
    }

    // Conversation-outcome (Part B, streaming parity): capture the chat user
    // turn + idle-guard verdict before `req` is consumed. Same gate + rules as
    // the non-streaming path (shared `begin_chat_turn`).
    let (chat_user_text, chat_was_idle, _chat_inflight) = begin_chat_turn(&req, session);

    let _permit = state.admission.acquire().await;
    let mut handle = engine
        .generate_tracked_stream(req)
        .await
        .map_err(|e| e.to_string())?;

    let mut accumulator = car_inference::StreamAccumulator::default();
    let request_id = msg.id.clone();

    while let Some(event) = handle.events.recv().await {
        let event_payload = match &event {
            car_inference::StreamEvent::TextDelta(text) => {
                serde_json::json!({"type": "text", "data": text})
            }
            car_inference::StreamEvent::ToolCallStart { name, index, .. } => {
                serde_json::json!({"type": "tool_start", "name": name, "index": index})
            }
            car_inference::StreamEvent::ToolCallDelta {
                index,
                arguments_delta,
            } => serde_json::json!({
                "type": "tool_delta",
                "index": index,
                "data": arguments_delta,
            }),
            car_inference::StreamEvent::Usage {
                input_tokens,
                output_tokens,
                cache_read_input_tokens,
                cache_creation_input_tokens,
            } => serde_json::json!({
                "type": "usage",
                "input_tokens": input_tokens,
                "output_tokens": output_tokens,
                "cache_read_input_tokens": cache_read_input_tokens,
                "cache_creation_input_tokens": cache_creation_input_tokens,
            }),
            car_inference::StreamEvent::StopReason(reason) => {
                serde_json::json!({"type": "stop_reason", "data": reason})
            }
            // Done is delivered as the JSON-RPC response, not a
            // notification — matches the NAPI contract where the
            // standalone function's return value is the accumulated
            // result and the callback only sees in-progress events.
            car_inference::StreamEvent::Done { .. } => {
                accumulator.push(&event);
                continue;
            }
        };

        let notif = serde_json::json!({
            "jsonrpc": "2.0",
            "method": "inference.stream.event",
            "params": {
                "request_id": request_id,
                "event": event_payload,
            },
        });
        if let Ok(text) = serde_json::to_string(&notif) {
            let _ = session
                .channel
                .write
                .lock()
                .await
                .send(Message::Text(text.into()))
                .await;
        }
        accumulator.push(&event);
    }

    let (text, tool_calls, usage, stop_reason) = accumulator.finish_with_usage();

    // Record inference token telemetry into the session event log as deep
    // telemetry (§3.5.1) so trajectory-level token totals
    // (`EventLog::metrics_totals`) reflect model cost, not just tool
    // latency. Emitted **once** from the accumulator's reconciled usage —
    // providers (e.g. Anthropic) emit cumulative `Usage` multiple times
    // per stream, so metering inside the loop would double-count. The
    // dedicated `InferenceMetered` kind keeps this out of action stats.
    if let Some(u) = &usage {
        session.runtime.log.lock().await.append_metered(
            car_eventlog::EventKind::InferenceMetered,
            None,
            None,
            HashMap::new(),
            car_eventlog::Metrics::inference(u.prompt_tokens, u.completion_tokens, None),
        );
    }

    // Conversation-outcome (Part B, streaming parity): score the session's PRIOR
    // chat turn by this new user turn, then stash this turn's trace_id/model for
    // the next round. They come from the `TrackedStream` handle (minted by the
    // same `record_start` the tap task resolves), so credit lands on the exact
    // model that produced the turn — the streaming analogue of `infer`'s
    // `InferenceResult`. What's load-bearing is that the PRIOR turn's trace still
    // survives in the tracker's `pending` set across the inter-turn gap; the only
    // thing that evicts it early is the 300s pending-sweep, identical to the
    // non-streaming path (a long-idle session loses the signal either way).
    // Best-effort: never fails the response.
    if let Some(user_text) = chat_user_text.filter(|_| chat_was_idle) {
        let mut slot = session.last_chat_turn.lock().await;
        let tracker_arc = engine.outcome_tracker();
        let mut tracker = tracker_arc.write().await;
        let tally = score_and_remember_chat_turn(
            &mut slot,
            &mut tracker,
            user_text,
            &text,
            &handle.trace_id,
            &handle.model_used,
        );
        if tally.submitted > 0 {
            tracing::debug!(
                submitted = tally.submitted,
                resolved = tally.resolved,
                "conversation-outcome recorded for prior chat turn (streaming)"
            );
        }
    }

    Ok(serde_json::json!({
        "text": text,
        "tool_calls": tool_calls,
        "usage": usage,
        "stop_reason": stop_reason,
        // Parity with non-streaming `infer`: surface the trace + model so a
        // client can attribute the turn / resolve outcomes later.
        "trace_id": handle.trace_id,
        "model_used": handle.model_used,
    }))
}

async fn handle_embed(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let req: car_inference::EmbedRequest =
        serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {}", e))?;
    // Embeds load their own model weights; share the same admission
    // gate as generations so a burst of embed requests can't smuggle
    // around the concurrency cap.
    let _permit = state.admission.acquire().await;
    let result = engine.embed(req).await.map_err(|e| e.to_string())?;
    Ok(serde_json::json!({"embeddings": result}))
}

async fn handle_classify(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let req: car_inference::ClassifyRequest =
        serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {}", e))?;
    let _permit = state.admission.acquire().await;
    let result = engine.classify(req).await.map_err(|e| e.to_string())?;
    Ok(serde_json::json!({"classifications": result}))
}

/// Surface the current admission state so the menubar tray and
/// `car daemon status` can show "queued: N" / "permits: P/T". Read-only
/// snapshot — racy by definition but correct enough for status panels.
fn handle_admission_status(state: &ServerState) -> Result<Value, String> {
    let total = state.admission.permits();
    let available = state.admission.permits_available();
    let in_use = total.saturating_sub(available);
    Ok(serde_json::json!({
        "permits_total": total,
        "permits_available": available,
        "permits_in_use": in_use,
        "env_override": crate::admission::ENV_MAX_CONCURRENT,
    }))
}

async fn handle_tokenize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let model = msg
        .params
        .get("model")
        .and_then(|v| v.as_str())
        .ok_or("missing 'model' parameter")?;
    let text = msg
        .params
        .get("text")
        .and_then(|v| v.as_str())
        .ok_or("missing 'text' parameter")?;
    let engine = get_inference_engine(state);
    let ids = engine
        .tokenize(model, text)
        .await
        .map_err(|e| e.to_string())?;
    Ok(serde_json::json!({"tokens": ids}))
}

async fn handle_detokenize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let model = msg
        .params
        .get("model")
        .and_then(|v| v.as_str())
        .ok_or("missing 'model' parameter")?;
    let tokens: Vec<u32> = msg
        .params
        .get("tokens")
        .and_then(|v| v.as_array())
        .ok_or("missing 'tokens' parameter")?
        .iter()
        .map(|t| {
            t.as_u64()
                .and_then(|n| u32::try_from(n).ok())
                .ok_or_else(|| "tokens[] must be u32 values".to_string())
        })
        .collect::<Result<Vec<_>, _>>()?;
    let engine = get_inference_engine(state);
    let text = engine
        .detokenize(model, &tokens)
        .await
        .map_err(|e| e.to_string())?;
    Ok(serde_json::json!({"text": text}))
}

/// `models.register` — persist a user-supplied `ModelSchema` to
/// `~/.car/models.json` (Parslee-ai/car-releases#39). Replaces any
/// existing entry with the same `id`. Returns `{id, registered}`.
///
/// **Phase 1 limitation**: the daemon's live `UnifiedRegistry` is
/// not updated in-process — the new model becomes visible to
/// `models.list`, `infer`, `infer_stream` on the **next daemon
/// boot** when `load_user_config` re-reads the file. This is
/// enough to unblock opencode's setup flow (register ahead of
/// time, then start the daemon). Hot-update requires either an
/// `RwLock<InferenceEngine>` on `ServerState` or an
/// interior-mutable `UnifiedRegistry`; both touch 20+ call sites
/// and are tracked as a follow-up.
///
/// Until hot-update lands, callers SHOULD register their models
/// before issuing `infer` calls against them, and operators
/// SHOULD restart the daemon after batches of model
/// registrations.
async fn handle_models_register(
    req: &JsonRpcMessage,
    _state: &Arc<ServerState>,
) -> Result<Value, String> {
    // The params shape mirrors v0.7's FFI `rt.registerModel(schemaJson)`:
    // either the bare `ModelSchema` value, OR `{ schema: ModelSchema }`.
    // Honor both so existing in-process callers don't have to reshape.
    let schema_value = match req.params.get("schema") {
        Some(v) => v.clone(),
        None => req.params.clone(),
    };
    let schema: car_inference::ModelSchema =
        serde_json::from_value(schema_value).map_err(|e| format!("invalid ModelSchema: {e}"))?;
    let id = schema.id.clone();

    // Resolve the models.json path the same way UnifiedRegistry does:
    // `<models_dir>/../models.json` where models_dir defaults to
    // `~/.car/models/`. We read whatever's there, swap in the new
    // entry, and write back atomically.
    let home = std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .ok_or_else(|| "no HOME / USERPROFILE in env".to_string())?;
    let car_dir = std::path::PathBuf::from(home).join(".car");
    std::fs::create_dir_all(&car_dir).map_err(|e| format!("create {}: {e}", car_dir.display()))?;
    let path = car_dir.join("models.json");

    let mut models: Vec<car_inference::ModelSchema> = if path.exists() {
        let text =
            std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
        if text.trim().is_empty() {
            Vec::new()
        } else {
            serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))?
        }
    } else {
        Vec::new()
    };
    // Replace existing entry with the same id, else append.
    if let Some(slot) = models.iter_mut().find(|m| m.id == id) {
        *slot = schema;
    } else {
        models.push(schema);
    }
    let json =
        serde_json::to_string_pretty(&models).map_err(|e| format!("serialize models.json: {e}"))?;
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
    std::fs::rename(&tmp, &path)
        .map_err(|e| format!("rename {} -> {}: {e}", tmp.display(), path.display()))?;
    Ok(serde_json::json!({
        "id": id,
        "registered": true,
        "path": path.to_string_lossy(),
        "note": "Daemon restart required for live UnifiedRegistry visibility \
                 (Parslee-ai/car-releases#39 phase 1). The model is persisted; \
                 next car-server boot loads it via UnifiedRegistry::load_user_config.",
    }))
}

/// `models.unregister` — remove an entry from `~/.car/models.json`
/// by id (Parslee-ai/car#186 — symmetric to `models.register`).
/// Returns `{ id, unregistered, path }` on success. Returns an error
/// when the model isn't present.
///
/// **Phase 1 limitation** (same as `models.register`): the daemon's
/// live `UnifiedRegistry` is not rebuilt — the removal takes effect
/// on the next daemon boot. Callers SHOULD restart the daemon after
/// a batch of unregistrations if they expect `models.list_unified`
/// to reflect the change immediately.
async fn handle_models_unregister(
    req: &JsonRpcMessage,
    _state: &Arc<ServerState>,
) -> Result<Value, String> {
    // Params shape mirrors the CLI flag: `{ id: string }`. Bare-string
    // params are honored for symmetry with the register handler's
    // tolerant shape (`{schema: ...}` OR bare schema).
    let id = match req.params.get("id") {
        Some(v) => v
            .as_str()
            .ok_or_else(|| "`id` must be a string".to_string())?
            .to_string(),
        None => match req.params.as_str() {
            Some(s) => s.to_string(),
            None => return Err("missing `id` parameter".to_string()),
        },
    };

    let home = std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .ok_or_else(|| "no HOME / USERPROFILE in env".to_string())?;
    let car_dir = std::path::PathBuf::from(home).join(".car");
    let path = car_dir.join("models.json");

    if !path.exists() {
        // Idempotent no-op: with no models.json the desired end-state (id not in
        // the user registry) already holds. The UI's Remove on a builtin-catalog
        // model that was never user-registered hit this as an alarming -32603
        // "no models.json — nothing to unregister" (PAR-7266). Report success.
        return Ok(serde_json::json!({
            "id": id,
            "unregistered": false,
            "note": "no models.json — nothing to unregister (already absent)",
        }));
    }
    let text =
        std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
    let mut models: Vec<car_inference::ModelSchema> = if text.trim().is_empty() {
        Vec::new()
    } else {
        serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))?
    };
    let before = models.len();
    models.retain(|m| m.id != id);
    if models.len() == before {
        // Not in the user registry (e.g. a builtin-catalog model the UI offered a
        // Remove button for) — idempotent no-op, not a UI-surfaced error (PAR-7266).
        return Ok(serde_json::json!({
            "id": id,
            "unregistered": false,
            "note": "model not in models.json — nothing to unregister",
        }));
    }
    let json =
        serde_json::to_string_pretty(&models).map_err(|e| format!("serialize models.json: {e}"))?;
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
    std::fs::rename(&tmp, &path)
        .map_err(|e| format!("rename {} -> {}: {e}", tmp.display(), path.display()))?;
    Ok(serde_json::json!({
        "id": id,
        "unregistered": true,
        "path": path.to_string_lossy(),
        "note": "Daemon restart required for live UnifiedRegistry visibility \
                 (phase 1, matching models.register).",
    }))
}

fn handle_models_list(state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let models = engine.list_models();
    serde_json::to_value(&models).map_err(|e| e.to_string())
}

fn handle_models_list_unified(state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let models = engine.list_models_unified();
    serde_json::to_value(&models).map_err(|e| e.to_string())
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ModelSearchParams {
    #[serde(default)]
    query: Option<String>,
    #[serde(default)]
    capability: Option<car_inference::ModelCapability>,
    #[serde(default)]
    provider: Option<String>,
    #[serde(default)]
    local_only: bool,
    #[serde(default)]
    available_only: bool,
    #[serde(default)]
    limit: Option<usize>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ModelSearchEntry {
    #[serde(flatten)]
    info: car_inference::ModelInfo,
    family: String,
    version: String,
    tags: Vec<String>,
    pullable: bool,
    upgrade: Option<car_inference::ModelUpgrade>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ModelSearchResponse {
    models: Vec<ModelSearchEntry>,
    upgrades: Vec<car_inference::ModelUpgrade>,
    total: usize,
    available: usize,
    local: usize,
    remote: usize,
}

fn handle_models_search(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let params: ModelSearchParams =
        serde_json::from_value(req.params.clone()).unwrap_or(ModelSearchParams {
            query: None,
            capability: None,
            provider: None,
            local_only: false,
            available_only: false,
            limit: None,
        });
    let engine = get_inference_engine(state);
    let upgrades = engine.available_model_upgrades();
    let upgrades_by_from: HashMap<String, car_inference::ModelUpgrade> = upgrades
        .iter()
        .cloned()
        .map(|upgrade| (upgrade.from_id.clone(), upgrade))
        .collect();
    let query = params
        .query
        .as_deref()
        .map(str::trim)
        .filter(|q| !q.is_empty())
        .map(|q| q.to_ascii_lowercase());
    let provider = params
        .provider
        .as_deref()
        .map(str::trim)
        .filter(|p| !p.is_empty())
        .map(|p| p.to_ascii_lowercase());

    let mut entries: Vec<ModelSearchEntry> = engine
        .list_schemas()
        .into_iter()
        .filter(|schema| {
            if let Some(capability) = params.capability {
                if !schema.has_capability(capability) {
                    return false;
                }
            }
            if let Some(provider) = provider.as_deref() {
                if schema.provider.to_ascii_lowercase() != provider {
                    return false;
                }
            }
            if params.local_only && !schema.is_local() {
                return false;
            }
            if params.available_only && !schema.available {
                return false;
            }
            if let Some(query) = query.as_deref() {
                let capability_text = schema
                    .capabilities
                    .iter()
                    .map(|cap| format!("{cap:?}").to_ascii_lowercase())
                    .collect::<Vec<_>>()
                    .join(" ");
                let haystack = format!(
                    "{} {} {} {} {} {}",
                    schema.id,
                    schema.name,
                    schema.provider,
                    schema.family,
                    schema.tags.join(" "),
                    capability_text
                )
                .to_ascii_lowercase();
                if !haystack.contains(query) {
                    return false;
                }
            }
            true
        })
        .map(|schema| {
            let pullable = !schema.available
                && matches!(
                    schema.source,
                    car_inference::ModelSource::Local { .. }
                        | car_inference::ModelSource::Mlx { .. }
                );
            let info = car_inference::ModelInfo::from(&schema);
            let upgrade = upgrades_by_from.get(&schema.id).cloned();
            ModelSearchEntry {
                info,
                family: schema.family,
                version: schema.version,
                tags: schema.tags,
                pullable,
                upgrade,
            }
        })
        .collect();
    entries.sort_by(|a, b| {
        b.info
            .available
            .cmp(&a.info.available)
            .then(b.info.is_local.cmp(&a.info.is_local))
            .then(a.info.name.cmp(&b.info.name))
    });
    if let Some(limit) = params.limit {
        entries.truncate(limit);
    }

    let total = entries.len();
    let available = entries.iter().filter(|entry| entry.info.available).count();
    let local = entries.iter().filter(|entry| entry.info.is_local).count();
    let response = ModelSearchResponse {
        models: entries,
        upgrades,
        total,
        available,
        local,
        remote: total.saturating_sub(local),
    };
    serde_json::to_value(response).map_err(|e| e.to_string())
}

/// Parse an optional serde enum param: absent/null → `None` (caller defaults);
/// present-but-unparseable → a clear invalid-params error rather than a silent
/// fallback, so a host app learns it sent a bad value.
fn optional_enum_param<T: serde::de::DeserializeOwned>(
    req: &JsonRpcMessage,
    key: &str,
) -> Result<Option<T>, String> {
    match req.params.get(key) {
        None | Some(Value::Null) => Ok(None),
        Some(v) => serde_json::from_value(v.clone())
            .map(Some)
            .map_err(|_| format!("invalid '{key}': {v}")),
    }
}

/// Parse `use_case`/`tier`/`cloud_ok` from JSON-RPC params and run the
/// recommender. `HardwareInfo::detect()` runs per call — acceptable because
/// recommend/setup_plan are onboarding-frequency (not the inference hot path),
/// and detection is a couple of cheap, non-blocking system probes.
fn recommend_from_params(
    req: &JsonRpcMessage,
    engine: &car_inference::InferenceEngine,
) -> Result<car_inference::RecommendationSet, String> {
    let use_case =
        optional_enum_param::<car_inference::UseCase>(req, "use_case")?.unwrap_or_default();
    let tier = optional_enum_param::<car_inference::QualityTier>(req, "tier")?.unwrap_or_default();
    let privacy = if req
        .params
        .get("cloud_ok")
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
    {
        car_inference::Privacy::CloudOk
    } else {
        car_inference::Privacy::OnDevice
    };
    let hw = car_inference::HardwareInfo::detect();
    let schemas = engine.list_schemas();
    let refs: Vec<&car_inference::ModelSchema> = schemas.iter().collect();
    Ok(car_inference::recommend(
        &refs, &hw, use_case, tier, privacy,
    ))
}

/// `models.recommend` — ranked, explained model picks for this machine + intent.
fn handle_models_recommend(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let set = recommend_from_params(req, engine)?;
    serde_json::to_value(set).map_err(|e| e.to_string())
}

/// `models.setup_plan` — a concrete onboarding plan the host/SDK can present:
/// machine description, the top pick, alternatives, and what needs more memory.
fn handle_models_setup_plan(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let set = recommend_from_params(req, engine)?;
    let hw = car_inference::HardwareInfo::detect();
    let mut picks = set.picks.into_iter();
    let recommended = picks.next();
    let alternatives: Vec<_> = picks.collect();
    serde_json::to_value(serde_json::json!({
        "machine": describe_machine_for_plan(&hw),
        "recommended": recommended,
        "alternatives": alternatives,
        "needs_more_memory": set.not_enough_memory,
        "note": set.note,
    }))
    .map_err(|e| e.to_string())
}

/// Plain-language one-liner about the host machine for setup plans.
fn describe_machine_for_plan(hw: &car_inference::HardwareInfo) -> String {
    use car_inference::hardware::SupportedAcceleration::*;
    match hw.supported_acceleration() {
        Apple { unified_memory_mb } => format!(
            "Apple Silicon, {} GB unified memory (Metal)",
            unified_memory_mb / 1024
        ),
        Cuda { device_memory_mb } => match device_memory_mb {
            Some(mb) => format!("NVIDIA GPU, {} GB VRAM (CUDA)", mb / 1024),
            None => "NVIDIA GPU (CUDA)".to_string(),
        },
        UnsupportedDiscreteGpu { name, .. } => format!(
            "{} GB RAM, CPU inference ({name} not yet supported)",
            hw.total_ram_mb / 1024
        ),
        Cpu => format!("{} GB RAM, CPU inference", hw.total_ram_mb / 1024),
    }
}

fn handle_models_upgrades(state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    serde_json::to_value(serde_json::json!({
        "upgrades": engine.available_model_upgrades()
    }))
    .map_err(|e| e.to_string())
}

/// `models.detect_upgrades` — curated + upstream-aware findings (channel-gated,
/// cached, offline-safe).
async fn handle_models_detect_upgrades(state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let findings = engine.detect_upgrades().await;
    serde_json::to_value(serde_json::json!({ "upgrades": findings })).map_err(|e| e.to_string())
}

/// `models.check_upgrade_nudge` — the current nudge decision (poll form). The
/// daemon also pushes `models.upgrade_available` proactively; this lets a
/// client ask on demand. `inference_active` defaults to false.
async fn handle_models_check_upgrade_nudge(
    req: &JsonRpcMessage,
    state: &ServerState,
) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let inference_active = req
        .params
        .get("inference_active")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let (decision, _state) = engine.check_upgrade_nudge(inference_active).await;
    serde_json::to_value(decision).map_err(|e| e.to_string())
}

/// `models.dismiss_upgrade` — remember that the user waved away a nudge.
fn handle_models_dismiss_upgrade(
    req: &JsonRpcMessage,
    state: &ServerState,
) -> Result<Value, String> {
    let key = req
        .params
        .get("dismiss_key")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .ok_or("missing or empty 'dismiss_key' parameter")?;
    let engine = get_inference_engine(state);
    engine
        .dismiss_upgrade_nudge(key)
        .map_err(|e| e.to_string())?;
    Ok(serde_json::json!({ "dismissed": key }))
}

/// `models.check_concierge` — the current concierge suggestions (poll form).
/// The daemon also pushes `models.suggestion_available` proactively; this lets
/// a client ask on demand. `inference_active` defaults to false.
async fn handle_models_check_concierge(
    req: &JsonRpcMessage,
    state: &ServerState,
) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let inference_active = req
        .params
        .get("inference_active")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let (suggestions, _state) = engine.check_concierge(inference_active).await;
    serde_json::to_value(serde_json::json!({ "suggestions": suggestions }))
        .map_err(|e| e.to_string())
}

/// `models.dismiss_suggestion` — remember that the user waved away a concierge
/// suggestion, so it is never surfaced again.
fn handle_models_dismiss_suggestion(
    req: &JsonRpcMessage,
    state: &ServerState,
) -> Result<Value, String> {
    let key = req
        .params
        .get("dismiss_key")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .ok_or("missing or empty 'dismiss_key' parameter")?;
    let engine = get_inference_engine(state);
    engine
        .dismiss_concierge_suggestion(key)
        .map_err(|e| e.to_string())?;
    Ok(serde_json::json!({ "dismissed": key }))
}

/// `concierge.status` — the ambient concierge view (Phase C1): per-lane
/// usage + friction, the current grounded decision (Observe or
/// Act+suggestion), and per-model health. Pull, not push.
async fn handle_concierge_status(
    req: &JsonRpcMessage,
    state: &ServerState,
) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    // Don't let a status pull surface a proactive Act while the user is
    // mid-inference — mirror the proactive path's defer.
    let inference_active = req
        .params
        .get("inference_active")
        .and_then(|v| v.as_bool())
        .unwrap_or_else(|| state.admission.in_flight() > 0);
    let status = engine.concierge_status(inference_active).await;
    serde_json::to_value(status).map_err(|e| e.to_string())
}

/// `concierge.dismiss` — record a *labeled* dismissal (Phase B4/C1):
/// `{ dismiss_key, reason }` where reason is one of not_now / wrong /
/// too_expensive / privacy / never_for_project. Permanent reasons
/// suppress the suggestion forever; not_now only cools it down.
fn handle_concierge_dismiss(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let key = req
        .params
        .get("dismiss_key")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .ok_or("missing or empty 'dismiss_key' parameter")?;
    let reason: car_inference::DismissReason = req
        .params
        .get("reason")
        .cloned()
        .map(serde_json::from_value)
        .transpose()
        .map_err(|e| format!("invalid 'reason': {e}"))?
        .unwrap_or(car_inference::DismissReason::NotNow);
    let engine = get_inference_engine(state);
    engine
        .dismiss_concierge_labeled(key, reason)
        .map_err(|e| e)?;
    Ok(serde_json::json!({ "dismissed": key, "reason": reason }))
}

/// `concierge.defaults` — all configured lane→model defaults (Phase D1).
fn handle_concierge_defaults(state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    serde_json::to_value(engine.lane_defaults()).map_err(|e| e.to_string())
}

/// `concierge.set_default { use_case, model_id, project? }` — set the
/// default model for a lane (Phase D1). `use_case` is the snake_case
/// lane name (assistant/coding/…).
async fn handle_concierge_set_default(
    req: &JsonRpcMessage,
    state: &ServerState,
) -> Result<Value, String> {
    let use_case: car_inference::UseCase = req
        .params
        .get("use_case")
        .cloned()
        .map(serde_json::from_value)
        .transpose()
        .map_err(|e| format!("invalid 'use_case': {e}"))?
        .ok_or("missing 'use_case'")?;
    let model_id = req
        .params
        .get("model_id")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .ok_or("missing or empty 'model_id'")?;
    let project = req
        .params
        .get("project")
        .and_then(|v| v.as_str())
        .map(str::to_string);
    let engine = get_inference_engine(state);
    engine
        .user_set_lane_default(use_case, model_id, project)
        .await?;
    Ok(serde_json::json!({ "set": model_id, "use_case": use_case }))
}

/// `concierge.clear_default { use_case, project? }` — remove a lane
/// default (Phase D1; also the rollback path in D3).
async fn handle_concierge_clear_default(
    req: &JsonRpcMessage,
    state: &ServerState,
) -> Result<Value, String> {
    let use_case: car_inference::UseCase = req
        .params
        .get("use_case")
        .cloned()
        .map(serde_json::from_value)
        .transpose()
        .map_err(|e| format!("invalid 'use_case': {e}"))?
        .ok_or("missing 'use_case'")?;
    let project = req
        .params
        .get("project")
        .and_then(|v| v.as_str())
        .map(str::to_string);
    let engine = get_inference_engine(state);
    let removed = engine.user_clear_lane_default(use_case, project).await?;
    Ok(serde_json::json!({ "cleared": removed }))
}

/// `concierge.apply { use_case, model_id, project? }` — closed-loop
/// "set it up" (Phase D3): acquire the model + set it as the lane
/// default, capturing the prior default so it's reversible. User-
/// consented (this is a direct user action).
async fn handle_concierge_apply(
    req: &JsonRpcMessage,
    state: &ServerState,
) -> Result<Value, String> {
    let use_case: car_inference::UseCase = req
        .params
        .get("use_case")
        .cloned()
        .map(serde_json::from_value)
        .transpose()
        .map_err(|e| format!("invalid 'use_case': {e}"))?
        .ok_or("missing 'use_case'")?;
    let model_id = req
        .params
        .get("model_id")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .ok_or("missing or empty 'model_id'")?;
    let project = req
        .params
        .get("project")
        .and_then(|v| v.as_str())
        .map(str::to_string);
    let engine = get_inference_engine(state);
    let result = engine.apply_concierge(use_case, model_id, project).await?;
    serde_json::to_value(result).map_err(|e| e.to_string())
}

/// `concierge.rollback { use_case, project? }` — revert a lane default to
/// its value before the last `apply` (Phase D3).
async fn handle_concierge_rollback(
    req: &JsonRpcMessage,
    state: &ServerState,
) -> Result<Value, String> {
    let use_case: car_inference::UseCase = req
        .params
        .get("use_case")
        .cloned()
        .map(serde_json::from_value)
        .transpose()
        .map_err(|e| format!("invalid 'use_case': {e}"))?
        .ok_or("missing 'use_case'")?;
    let project = req
        .params
        .get("project")
        .and_then(|v| v.as_str())
        .map(str::to_string);
    let engine = get_inference_engine(state);
    let restored = engine.rollback_lane(use_case, project).await?;
    Ok(serde_json::json!({ "restored": restored }))
}

/// `concierge.actions { limit? }` — the audit log of consented concierge
/// actions (Phase D2), most recent last.
fn handle_concierge_actions(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let limit = req
        .params
        .get("limit")
        .and_then(|v| v.as_u64())
        .unwrap_or(50) as usize;
    let engine = get_inference_engine(state);
    serde_json::to_value(serde_json::json!({ "actions": engine.concierge_actions(limit) }))
        .map_err(|e| e.to_string())
}

/// `concierge.refresh_catalog` — fetch + verify the signed model catalog
/// from the configured source (Phase E1). New models apply at next
/// daemon start (the registry is immutable at runtime).
async fn handle_concierge_refresh_catalog(state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let count = engine.refresh_catalog().await?;
    Ok(serde_json::json!({
        "refreshed": count,
        "note": "verified catalog cached; new models apply on next daemon restart"
    }))
}

/// `concierge.ask { question }` — conversational concierge (Phase
/// F1/F2): a grounded, ledger-backed answer about the user's models. The
/// LLM explains only from the assembled evidence; it can't invent models
/// or assert fit.
async fn handle_concierge_ask(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let question = req
        .params
        .get("question")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .ok_or("missing or empty 'question'")?;
    let engine = get_inference_engine(state);
    let answer = engine.concierge_ask(question).await?;
    Ok(serde_json::json!({ "answer": answer }))
}

/// `models.update_prefs_get` — current update preferences.
fn handle_models_update_prefs_get(state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    serde_json::to_value(engine.update_prefs()).map_err(|e| e.to_string())
}

/// `models.update_prefs_set` — replace update preferences. Params are the
/// `UpdatePreferences` shape (all fields optional; missing → defaults).
fn handle_models_update_prefs_set(
    req: &JsonRpcMessage,
    state: &ServerState,
) -> Result<Value, String> {
    let prefs: car_inference::UpdatePreferences = serde_json::from_value(req.params.clone())
        .map_err(|e| format!("invalid preferences: {e}"))?;
    let engine = get_inference_engine(state);
    engine.set_update_prefs(&prefs).map_err(|e| e.to_string())?;
    serde_json::to_value(prefs).map_err(|e| e.to_string())
}

/// Run one proactive upgrade check and push a `models.upgrade_available`
/// nudge to subscribers if warranted. Stamps `last_nudge_secs` after sending
/// so the per-day throttle holds across ticks. The daemon calls this on a
/// periodic timer; the nudge logic itself decides whether to actually surface
/// anything (policy/throttle/dismissals).
pub async fn run_upgrade_nudge_check(state: &Arc<ServerState>) {
    let engine = get_inference_engine(state);
    // Defer the nudge if any inference is in flight (held admission permits) —
    // the real "machine is busy" signal, not a guess.
    let inference_active = state.admission.in_flight() > 0;
    let (decision, mut nstate) = engine.check_upgrade_nudge(inference_active).await;
    if let Some(nudge) = decision.nudge {
        let delivered = broadcast_upgrade_nudge(state, &nudge).await;
        // Only burn the once-per-day throttle when the nudge actually reached
        // someone — otherwise a nudge fired with no UI connected would silence
        // the user for a day without them ever seeing it.
        if delivered > 0 {
            let now = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_secs())
                .unwrap_or(0);
            nstate.last_nudge_secs = now;
            let _ = nstate.save_to(&car_inference::NudgeState::default_path());
        }
    }
}

/// Push a `models.upgrade_available` notification to all subscribed UI clients,
/// returning how many it reached. Targets the same subscriber set the macOS
/// host already uses for pushed events (`a2ui_subscribers`) — the UI-push
/// channel — rather than standing up a parallel subscription. Mirrors
/// [`broadcast_a2ui_event`].
pub async fn broadcast_upgrade_nudge(
    state: &Arc<ServerState>,
    nudge: &car_inference::UpgradeNudge,
) -> usize {
    use futures::SinkExt;
    use tokio_tungstenite::tungstenite::Message;
    let subscribers: Vec<Arc<crate::session::WsChannel>> = state
        .a2ui_subscribers
        .lock()
        .await
        .values()
        .cloned()
        .collect();
    if subscribers.is_empty() {
        return 0;
    }
    let Ok(json) = serde_json::to_string(&serde_json::json!({
        "jsonrpc": "2.0",
        "method": "models.upgrade_available",
        "params": nudge,
    })) else {
        return 0;
    };
    let mut delivered = 0;
    for channel in subscribers {
        if channel
            .write
            .lock()
            .await
            .send(Message::Text(json.clone().into()))
            .await
            .is_ok()
        {
            delivered += 1;
        }
    }
    delivered
}

/// Run one proactive concierge check and push a `models.suggestion_available`
/// notification per unserved lane to subscribers. Stamps `last_concierge_secs`
/// after sending so the throttle holds across ticks. Mirrors
/// [`run_upgrade_nudge_check`] but on the concierge's independent cadence and
/// throttle field, so the two never starve each other.
pub async fn run_concierge_check(state: &Arc<ServerState>) {
    let engine = get_inference_engine(state);
    let inference_active = state.admission.in_flight() > 0;
    let (suggestions, mut nstate) = engine.check_concierge(inference_active).await;
    if suggestions.is_empty() {
        return;
    }
    let mut any_delivered = false;
    for suggestion in &suggestions {
        if broadcast_concierge_suggestion(state, suggestion).await > 0 {
            any_delivered = true;
        }
    }
    // Only burn the throttle when a suggestion actually reached someone —
    // otherwise a check that fired with no UI connected would silence the user
    // for a week without them ever seeing it.
    if any_delivered {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        nstate.last_concierge_secs = now;
        let _ = nstate.save_to(&car_inference::NudgeState::default_path());
    }
}

/// One pass of idle model-backend eviction. A long-running daemon under
/// the default 24 GB cache budget pins loaded model weights resident
/// forever — capacity eviction never fires below the cap — so RSS climbs
/// to a model's working set and never drops at idle (car-releases#67).
/// This sweeps backends untouched for `CAR_INFERENCE_MODEL_IDLE_SECS`
/// (default 300) out of the caches; their RAM is reclaimed once any
/// outstanding handles drop, which at idle is immediate.
///
/// Only sweeps if the engine has already been created — it never forces
/// lazy engine init just to sweep an empty cache. Meant to be driven on a
/// timer by the daemon.
pub async fn run_idle_backend_eviction(state: &Arc<ServerState>) {
    let Some(engine) = state.inference.get() else {
        return;
    };
    let (entries, bytes) = engine.evict_idle_backends();
    if entries > 0 {
        tracing::info!(
            entries,
            mb = bytes / (1024 * 1024),
            "evicted idle model backends; resident model memory released"
        );
    }
    // Supervised vllm-mlx server processes are managed alongside the in-process
    // backends: stop the ones idle past their TTL so a server-backed model
    // doesn't pin the GPU after use.
    let stopped = engine.evict_idle_vllm_servers().await;
    if stopped > 0 {
        tracing::info!(stopped, "stopped idle vllm-mlx servers");
    }
}

/// Push a `models.suggestion_available` notification to subscribed UI clients,
/// returning how many it reached. Targets the same `a2ui_subscribers` UI-push
/// channel as [`broadcast_upgrade_nudge`].
pub async fn broadcast_concierge_suggestion(
    state: &Arc<ServerState>,
    suggestion: &car_inference::ConciergeSuggestion,
) -> usize {
    use futures::SinkExt;
    use tokio_tungstenite::tungstenite::Message;
    let subscribers: Vec<Arc<crate::session::WsChannel>> = state
        .a2ui_subscribers
        .lock()
        .await
        .values()
        .cloned()
        .collect();
    if subscribers.is_empty() {
        return 0;
    }
    let Ok(json) = serde_json::to_string(&serde_json::json!({
        "jsonrpc": "2.0",
        "method": "models.suggestion_available",
        "params": suggestion,
    })) else {
        return 0;
    };
    let mut delivered = 0;
    for channel in subscribers {
        if channel
            .write
            .lock()
            .await
            .send(Message::Text(json.clone().into()))
            .await
            .is_ok()
        {
            delivered += 1;
        }
    }
    delivered
}

/// Bridges the (sync) download progress sink to async WS broadcasts: each
/// `DownloadEvent` is pushed onto an unbounded channel that a concurrent task
/// drains and broadcasts as `models.pull_progress`.
struct PullProgressSink {
    tx: tokio::sync::mpsc::UnboundedSender<car_inference::DownloadEvent>,
}

impl car_inference::DownloadProgress for PullProgressSink {
    fn on_event(&self, event: &car_inference::DownloadEvent) {
        // Unbounded send from a sync context; ignore if the receiver is gone.
        let _ = self.tx.send(event.clone());
    }
}

async fn handle_models_pull(
    msg: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let name = msg
        .params
        .get("name")
        .or_else(|| msg.params.get("id"))
        .or_else(|| msg.params.get("model"))
        .and_then(|v| v.as_str())
        .ok_or("missing 'name' parameter")?
        .to_string();
    let engine = get_inference_engine(state);

    // Stream progress live: events flow sink → channel → broadcaster task,
    // which runs concurrently with the download. Unbounded is safe here because
    // progress is file-level (O(files): Started + per-file Started/Completed +
    // Completed — a few dozen events per pull), not byte-level, so the channel
    // can't grow without bound.
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<car_inference::DownloadEvent>();
    let sink = car_inference::ProgressSink::new(Arc::new(PullProgressSink { tx }));
    let broadcaster_state = state.clone();
    let model_label = name.clone();
    let broadcaster = tokio::spawn(async move {
        while let Some(event) = rx.recv().await {
            broadcast_pull_progress(&broadcaster_state, &model_label, &event).await;
        }
    });

    let result = engine.pull_model_with_progress(&name, &sink).await;
    // Drop the sink so its sender closes, ending the broadcaster cleanly.
    drop(sink);
    // A broadcaster panic must not poison the (successful) pull — log and move on.
    if let Err(e) = broadcaster.await {
        tracing::warn!(error = %e, "pull-progress broadcaster task failed");
    }

    let path = result.map_err(|e| e.to_string())?;
    Ok(serde_json::json!({"path": path.display().to_string()}))
}

/// Push a `models.pull_progress` notification to subscribed UI clients.
/// Mirrors [`broadcast_upgrade_nudge`]; best-effort, no-op with no subscribers.
async fn broadcast_pull_progress(
    state: &Arc<ServerState>,
    model: &str,
    event: &car_inference::DownloadEvent,
) {
    use futures::SinkExt;
    use tokio_tungstenite::tungstenite::Message;
    let subscribers: Vec<Arc<crate::session::WsChannel>> = state
        .a2ui_subscribers
        .lock()
        .await
        .values()
        .cloned()
        .collect();
    if subscribers.is_empty() {
        return;
    }
    let Ok(json) = serde_json::to_string(&serde_json::json!({
        "jsonrpc": "2.0",
        "method": "models.pull_progress",
        "params": { "model": model, "event": event },
    })) else {
        return;
    };
    for channel in subscribers {
        let _ = channel
            .write
            .lock()
            .await
            .send(Message::Text(json.clone().into()))
            .await;
    }
}

async fn handle_skills_distill(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let events: Vec<car_memgine::TraceEvent> = serde_json::from_value(
        msg.params
            .get("events")
            .cloned()
            .unwrap_or(msg.params.clone()),
    )
    .map_err(|e| format!("invalid events: {}", e))?;

    let inference = get_inference_engine(state).clone();
    let engine = car_memgine::MemgineEngine::new(None).with_inference(inference);

    let skills = engine.distill_skills(&events).await;
    serde_json::to_value(&skills).map_err(|e| e.to_string())
}

/// Run memory consolidation against this client's session memgine
/// (or the daemon-owned per-agent memgine when bound — #170).
/// Returns the JSON `ConsolidationReport`.
async fn handle_memory_consolidate(
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let engine_arc = session.effective_memgine().await;
    let report = {
        let mut engine = engine_arc.lock().await;
        engine.consolidate().await
    };
    if let Some(id) = session.agent_id.lock().await.clone() {
        if let Err(e) = persist_agent_memgine(&id, &engine_arc).await {
            tracing::warn!(agent_id = %id, error = %e,
                "agent memgine persist after consolidate failed");
        }
    }
    serde_json::to_value(&report).map_err(|e| e.to_string())
}

/// `memory.utility_get` — read the live engine's utility-aware
/// retrieval blend (U-Mem). Returns `{ utility_weight,
/// utility_exploration }`.
///
/// Engine source: `session.memgine` — deliberately the same engine
/// `handle_memory_build_context`/`_fast` retrieve from, so this getter
/// and the setter always report/mutate the engine that live context
/// assembly reads. NOTE: that's `session.memgine`, not
/// `effective_memgine()` — `build_context` is itself the outlier that
/// skipped the #170 bound-memgine routing; when that gap is closed,
/// these two utility handlers must move with it. For a bound agent the
/// persistent default still arrives via the `.car/`-seeded per-agent
/// engine (`get_or_load_agent_memgine`); this runtime override is a
/// daemon-global knob over the shared engine.
async fn handle_memory_utility_get(
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let engine = session.memgine.lock().await;
    let (weight, exploration) = engine.utility_retrieval();
    Ok(serde_json::json!({
        "utility_weight": weight,
        "utility_exploration": exploration,
    }))
}

/// `memory.utility_set` — runtime override of the utility-aware
/// retrieval blend on `session.memgine` (see `memory.utility_get` for
/// the engine-source rationale). The **persistent** baseline comes from
/// `.car/config.toml` at engine construction; this mutates the live
/// engine on top of that and does NOT persist across daemon restarts.
///
/// Read-modify-write: an omitted field keeps the engine's current value,
/// so `{ utility_weight: 0.5 }` tunes weight without resetting
/// exploration (matching the optional param typing + paired getter).
/// Takes effect on the next context build. Returns the applied
/// `{ utility_weight, utility_exploration }` (post-clamp).
///
/// NOTE: the shared engine is daemon-global (one `shared_memgine` across
/// all WS sessions + MCP), so this shifts retrieval ranking for every
/// client — last-writer-wins. For per-team persistent tuning, prefer the
/// `.car/config.toml` `utility_weight`/`utility_exploration` keys.
async fn handle_memory_utility_set(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let (weight, exploration) = {
        let mut engine = session.memgine.lock().await;
        // Read-modify-write: default each missing field to the engine's
        // current value rather than 0.0, so a partial set doesn't silently
        // zero the field the caller didn't pass.
        let (cur_weight, cur_exploration) = engine.utility_retrieval();
        let weight = msg
            .params
            .get("utility_weight")
            .and_then(|v| v.as_f64())
            .unwrap_or(cur_weight);
        let exploration = msg
            .params
            .get("utility_exploration")
            .and_then(|v| v.as_f64())
            .unwrap_or(cur_exploration);
        engine.set_utility_retrieval(weight, exploration);
        engine.utility_retrieval()
    };
    Ok(serde_json::json!({
        "utility_weight": weight,
        "utility_exploration": exploration,
    }))
}

/// `cascade.run` — U-Mem Slice 5 live evolve loop. Runs the cost-aware
/// knowledge cascade (`car_memgine::cascade::run_cascade_async`) for real,
/// escalating cheapest-first on **observed** confidence: it walks the policy's
/// tiers within budget, runs each tier's real CAR mechanic, and stops once an
/// observed confidence meets the target.
///
/// Per the caller-injected-confidence design (CAR doesn't invent a confidence
/// its primitives don't produce): the daemon runs the tier **mechanics** and the
/// budget/target walk, while the **observed confidence** for `self_reflect` /
/// `tool_verify` is supplied by the caller in `observed` (they have no
/// authoritative confidence source). The mechanics per tier:
///   - `self_reflect` → `engine.reflect()` on the session memgine (a real side
///     effect: it ingests reflection insights into memory; skipped when
///     `dry_run` is set). Confidence: caller-supplied.
///   - `tool_verify`  → pass-through (CAR has no built-in verify tool — the
///     confidence/knowledge are **caller-attested** from whatever tool the caller
///     ran; the daemon does not verify).
///   - `human_expert` → the session's `ApprovalLedger` (the same HITL substrate
///     as `permission.*`) keyed by `cascade:<claim>` is the **authority**: a
///     prior **Approved** lets the tier contribute the caller's confidence, a
///     **Rejected** aborts the cascade, and an **undecided** claim forces the
///     tier's confidence to 0.0 (it can NEVER be accepted without a real
///     approval) and surfaces `pending_approval` so a host routes it through
///     `permission.approve` / `permission.reject` by that fingerprint. A
///     `human_expert` tier requires a non-empty `claim`.
///
/// Params: `{ current_confidence: f64, policy: CascadePolicy, observed:
/// { <tier>: { confidence: f64, knowledge?: string } }, claim?: string,
/// dry_run?: bool }`.
/// Returns `{ run: CascadeRun, pending_approval?: { fingerprint, claim } }`.
async fn handle_cascade_run(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &ServerState,
) -> Result<Value, String> {
    use car_memgine::cascade::{run_cascade_async, CascadePolicy, CascadeTier, TierResult};

    #[derive(serde::Deserialize)]
    struct ObservedTier {
        confidence: f64,
        #[serde(default)]
        knowledge: Option<String>,
    }

    let current_confidence = req
        .params
        .get("current_confidence")
        .and_then(|v| v.as_f64())
        .unwrap_or(0.0);
    let policy_val = req.params.get("policy").ok_or("missing 'policy'")?;
    let policy: CascadePolicy = serde_json::from_value(policy_val.clone())
        .map_err(|e| format!("invalid 'policy': {e}"))?;
    let observed: std::collections::HashMap<CascadeTier, ObservedTier> = req
        .params
        .get("observed")
        .map(|v| serde_json::from_value(v.clone()))
        .transpose()
        .map_err(|e| format!("invalid 'observed': {e}"))?
        .unwrap_or_default();
    let claim = req
        .params
        .get("claim")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    // When true, skip tier side effects (the `self_reflect` `reflect()` memory
    // mutation) — a side-effect-free preview that still walks the cascade on the
    // caller-supplied observed confidence. The `human_expert` ledger read is
    // read-only and always runs (the gate must hold even in a preview).
    let dry_run = req
        .params
        .get("dry_run")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let fingerprint = format!("cascade:{}", claim.trim());

    // A `human_expert` tier needs a non-blank claim: the durable approval is
    // keyed on `cascade:<claim>`, so an empty claim would collapse every
    // claimless cascade onto the shared `cascade:` fingerprint — one stale
    // decision would then bleed across logically independent requests.
    let has_human_tier = policy
        .tiers
        .iter()
        .any(|t| t.tier == CascadeTier::HumanExpert);
    if has_human_tier && claim.trim().is_empty() {
        return Err("a 'human_expert' tier requires a non-empty 'claim' (it keys the durable approval)".into());
    }

    // The engine that holds (and learns) this session's facts — `reflect()` must
    // ingest into the bound per-agent memgine when one is attached (#170), like
    // `consolidate`.
    let engine_arc = session.effective_memgine().await;

    let observed_ref = &observed;
    let fp_ref = fingerprint.as_str();
    let run = move |tier: CascadeTier| {
        let engine_arc = engine_arc.clone();
        async move {
            let obs = observed_ref.get(&tier);
            let caller_confidence = obs.map(|o| o.confidence).unwrap_or(0.0);
            let caller_knowledge = obs.and_then(|o| o.knowledge.clone());
            // self_reflect / tool_verify have no authoritative confidence source,
            // so the caller's observed value drives escalation (the approved
            // caller-injected design). human_expert is different — the ledger is
            // the authority, so its confidence is GATED on a recorded decision,
            // never on the caller's number.
            let (knowledge, confidence) = match tier {
                CascadeTier::SelfReflect => {
                    let knowledge = if dry_run {
                        caller_knowledge
                            .unwrap_or_else(|| "self_reflect: dry_run (no reflect)".to_string())
                    } else {
                        // Real side effect: reflect over conversation, ingesting insights.
                        let report = {
                            let mut engine = engine_arc.lock().await;
                            engine.reflect().await
                        };
                        caller_knowledge.unwrap_or_else(|| {
                            format!(
                                "self_reflect: {} insight(s) ingested ({} corrections, {} preferences, {} friction)",
                                report.insights_ingested,
                                report.corrections_found,
                                report.preferences_found,
                                report.friction_points_found,
                            )
                        })
                    };
                    (knowledge, caller_confidence)
                }
                CascadeTier::ToolVerify => {
                    let knowledge = caller_knowledge
                        .unwrap_or_else(|| "tool_verify: caller-supplied".to_string());
                    (knowledge, caller_confidence)
                }
                CascadeTier::HumanExpert => {
                    // SHARED daemon ledger (C1): the approving host connection
                    // is not this connection.
                    let decision = {
                        let ledger = state.approval_ledger.read().await;
                        ledger.lookup(fp_ref).map(|r| r.decision)
                    };
                    match decision {
                        // A human rejected this exact claim — abort the cascade.
                        Some(car_policy::ApprovalDecision::Rejected) => {
                            return Err(format!("human_expert rejected: {fp_ref}"));
                        }
                        // Approved → the tier may contribute the caller's confidence
                        // (the human endorsed the claim; the caller scores how
                        // confident given that endorsement).
                        Some(car_policy::ApprovalDecision::Approved) => {
                            let knowledge = caller_knowledge
                                .unwrap_or_else(|| format!("human_expert: approved ({fp_ref})"));
                            (knowledge, caller_confidence)
                        }
                        // Undecided → 0.0, NOT the caller's number. The tier cannot
                        // satisfy the target without a real approval, so the run
                        // can never report `accepted_tier=human_expert` while the
                        // claim is still pending. Acceptance is gated on the ledger.
                        None => {
                            let knowledge = caller_knowledge
                                .unwrap_or_else(|| format!("human_expert: awaiting approval ({fp_ref})"));
                            (knowledge, 0.0)
                        }
                    }
                }
            };
            Ok::<TierResult, String>(TierResult {
                knowledge,
                confidence,
            })
        }
    };

    let run_result = run_cascade_async(current_confidence, &policy, run).await?;

    // Surface a pending human approval when the human_expert tier ran but no
    // durable decision exists yet — the host routes it through the existing
    // `permission.approve`/`permission.reject` flow by this fingerprint.
    let human_ran = run_result
        .steps
        .iter()
        .any(|s| s.tier == CascadeTier::HumanExpert);
    let pending = if human_ran {
        state
            .approval_ledger
            .read()
            .await
            .lookup(&fingerprint)
            .is_none()
    } else {
        false
    };

    let mut resp = serde_json::json!({ "run": run_result });
    if pending {
        resp.as_object_mut().unwrap().insert(
            "pending_approval".into(),
            serde_json::json!({ "fingerprint": fingerprint, "claim": claim }),
        );
    }
    Ok(resp)
}

/// Assemble the governor's full live component-state input for this session —
/// ALL FIVE components, each from its real signal source:
/// - **Memory / Skills / Context** from the session memgine
///   (`evolution_component_states` — Memory backlog/churn, Skills degradation
///   rate, Context conversation-token saturation);
/// - **Harness** from the session event log
///   (`crate::evolution::harness_component_from_events` — the share of logged
///   events implicated in a recurring interaction-failure pattern);
/// - **Tools** from live connector health
///   (`crate::evolution::tools_component_from_connectors` — disconnected /
///   total connectors).
///
/// A component with no observable signal source (empty store, empty log, no
/// connectors configured) is omitted rather than fabricated at zero.
async fn assemble_evolution_components(
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Vec<car_memgine::self_evolution::ComponentState> {
    // Route through `effective_memgine` (S1) so a session bound to a
    // lifecycle agent (#169/#170) plans over the daemon-owned per-agent
    // engine, like every memory.* handler.
    let mut components = {
        let engine_arc = session.effective_memgine().await;
        let engine = engine_arc.lock().await;
        engine.evolution_component_states()
    };
    {
        let log = session.runtime.log.lock().await;
        if let Some(h) = crate::evolution::harness_component_from_events(log.events()) {
            components.push(h);
        }
    }
    state.ensure_connectors_loaded().await;
    let connectors = state.connectors().list().await;
    if let Some(t) = crate::evolution::tools_component_from_connectors(&connectors) {
        components.push(t);
    }
    components
}

/// Plan an evolution cycle over the session's **live** signals — the
/// self-evolution governor's host surface (arXiv 2507.21046, Slice 3 + the
/// daemon populaters). Folds all five components live via
/// [`assemble_evolution_components`] and runs the governor. `params.policy` is
/// an optional `EvolutionPolicy` `{ pressure_threshold?, budget? }`. Returns
/// the `EvolutionPlan` JSON `{ decisions, spent, evolve_now }`. Read-only — it
/// plans; dispatching is `evolution.run` (or the caller's own loop).
async fn handle_evolution_plan(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let policy: car_memgine::self_evolution::EvolutionPolicy = serde_json::from_value(
        req.params
            .get("policy")
            .cloned()
            .unwrap_or_else(|| serde_json::json!({})),
    )
    .map_err(|e| format!("invalid policy: {e}"))?;
    let components = assemble_evolution_components(session, state).await;
    let plan = car_memgine::self_evolution::plan_evolution(&components, &policy);
    serde_json::to_value(plan).map_err(|e| e.to_string())
}

/// Run one evolution cycle over the session's live signals — the real executor
/// behind the self-evolution governor (arXiv 2507.21046; the "remaining daemon
/// step" of `docs/proposals/self-evolution-governor.md`). Plans exactly like
/// `evolution.plan` (all five components live), then dispatches each
/// `EvolveNow` component in priority order:
///
/// - **Memory** → `engine.consolidate()`, sized by
///   `maintenance::decide_maintenance` off the live `memory_stats` (the
///   localized-vs-global choice is recorded; `consolidate()` is the single
///   live mechanism for both today).
/// - **Skills** → `engine.evolve_skills(failed_events, domain)` for every
///   domain `domains_needing_evolution` flags, with `failed_events` folded
///   from the session event log's failure records
///   (`crate::evolution::failed_trace_events` — per-action failures; the log
///   carries no state-before/after trajectories, so those fields are honest
///   `None`s). Errors `"no inference engine"` when the session engine has no
///   model — never a stub.
/// - **Harness** → the `harness_evolution` diagnose→gate→apply loop, HITL-
///   gated on the daemon's SHARED durable `ApprovalLedger`
///   (`~/.car/approvals.jsonl` — the same store `permission.*` writes, from
///   ANY connection; C1), fingerprint `harness:<component>:<patch-digest>`
///   (bound to the patch *content*, not diagnostic prose, so an approval
///   matches every re-diagnosis of the same change; C2):
///   - a mutation whose fingerprint a human already **approved** applies its
///     patch to the session runtime's live `HarnessConfig` under one atomic
///     read-modify-write (`Governance::HumanApproved`); **rejected** →
///     blocked;
///   - undecided mutations surface in `pending_approvals` (resolve via
///     `permission.approve`/`permission.reject` by fingerprint — on any
///     connection — then re-run);
///   - the regression gate (`EvolutionAgent::evaluate`) needs *candidate*
///     metrics measured on held-out telemetry after applying the mutation — a
///     single live cycle cannot measure that honestly, so auto-promotion only
///     runs when the caller supplies `harness_candidate_metrics` (and
///     optionally `harness_baseline_metrics`; baseline defaults to the live
///     session metrics). A gate-passed non-safety mutation then auto-applies
///     (`Governance::Promoted`); a safety-affecting one still goes to HITL.
///   - a supplied `harness_baseline_metrics` also REPLACES the Harness
///     planning signal (`harness_component_from_metrics` — failed/total
///     attempts) so the declared telemetry both elects and diagnoses the
///     component coherently.
/// - **Context / Tools** → recorded as `not_executable` with the documented
///   reason: neither has an autonomous mutation mechanism yet (context tuning
///   is a `.car/config.toml` change; connector repair is `connectors.*`).
///
/// `params`: `{ policy?, dry_run?, harness_baseline_metrics?,
/// harness_candidate_metrics? }`. `dry_run` skips every side effect (no
/// consolidate, no evolve, no config apply, no event append) and reports what
/// would run — including the `pending_approvals` that a real run would
/// surface (listing them is response data, not a side effect). `evolved`
/// lists only components that **applied a change** (S2) — an Ok-but-no-op
/// step (nothing to evolve, everything still pending approval, dry run) is
/// reported in `steps` with `applied: false` but not in `evolved`. At most
/// one `evolution.run` executes per session at a time; a concurrent second
/// call errs instead of overlapping (S3). Returns
/// `{ plan, steps, evolved, pending_approvals? }`; a real run appends one
/// `EvolutionTriggered` event (`data.source = "evolution.run"`) to the session
/// event log and, when bound to a lifecycle agent, persists the per-agent
/// memgine after a cycle that applied something (S1).
async fn handle_evolution_run(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    use car_memgine::harness_evolution::{
        mutation_fingerprint, EvolutionAgent, Governance, PromotionDecision,
    };
    use car_memgine::self_evolution::{EvolutionOutcome, EvolvableComponent};

    // Non-overlap guard (S3): the dispatcher spawns a task per request, so
    // even one connection can land two evolution.run frames concurrently —
    // which would double-dispatch consolidate/evolve and race the harness
    // apply. Held (RAII) until this handler returns.
    let _cycle_token = session
        .evolution_guard
        .try_begin()
        .ok_or("evolution.run already in flight on this session")?;

    let policy: car_memgine::self_evolution::EvolutionPolicy = serde_json::from_value(
        req.params
            .get("policy")
            .cloned()
            .unwrap_or_else(|| serde_json::json!({})),
    )
    .map_err(|e| format!("invalid policy: {e}"))?;
    let dry_run = req
        .params
        .get("dry_run")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let baseline_override: Option<car_eventlog::harness_metrics::HarnessMetrics> =
        match req.params.get("harness_baseline_metrics") {
            Some(v) if !v.is_null() => Some(
                serde_json::from_value(v.clone())
                    .map_err(|e| format!("invalid harness_baseline_metrics: {e}"))?,
            ),
            _ => None,
        };
    let candidate_metrics: Option<car_eventlog::harness_metrics::HarnessMetrics> =
        match req.params.get("harness_candidate_metrics") {
            Some(v) if !v.is_null() => Some(
                serde_json::from_value(v.clone())
                    .map_err(|e| format!("invalid harness_candidate_metrics: {e}"))?,
            ),
            _ => None,
        };

    let mut components = assemble_evolution_components(session, state).await;
    // S1: the same engine the assembler planned over — the daemon-owned
    // per-agent engine for bound sessions, else the session engine.
    let engine_arc = session.effective_memgine().await;

    // Pre-fold the session-log-derived executor inputs once (single lock).
    let (failed_events, live_metrics) = {
        let log = session.runtime.log.lock().await;
        (
            crate::evolution::failed_trace_events(log.events()),
            car_eventlog::harness_metrics::compute_harness_metrics(log.events()),
        )
    };
    let has_baseline_override = baseline_override.is_some();
    let baseline_metrics = baseline_override.unwrap_or(live_metrics);
    // A caller-supplied baseline is the declared harness telemetry for this
    // cycle: it drives the diagnosis below AND the Harness planning signal —
    // otherwise a held-out baseline could diagnose mutations the plan never
    // dispatches (the live session log may be empty).
    if has_baseline_override {
        components.retain(|c| {
            c.component != car_memgine::self_evolution::EvolvableComponent::Harness
        });
        if let Some(h) = crate::evolution::harness_component_from_metrics(&baseline_metrics) {
            components.push(h);
        }
    }
    let harness_mutations = EvolutionAgent::new().diagnose(&baseline_metrics);

    let pending_approvals: std::sync::Mutex<Vec<Value>> = std::sync::Mutex::new(Vec::new());
    let failed_events = &failed_events;
    let harness_mutations = &harness_mutations;
    let baseline_metrics = &baseline_metrics;
    let candidate_metrics = &candidate_metrics;
    let pending_ref = &pending_approvals;
    let engine_ref = &engine_arc;

    let run = |component: EvolvableComponent| {
        let engine = engine_ref.clone();
        async move {
            match component {
                EvolvableComponent::Memory => {
                    crate::evolution::run_memory_evolution(&engine, dry_run).await
                }
                EvolvableComponent::Skills => {
                    crate::evolution::run_skills_evolution(&engine, failed_events, dry_run).await
                }
                EvolvableComponent::Harness => {
                    if harness_mutations.is_empty() {
                        return Ok(EvolutionOutcome::no_op(
                            "no harness mutations diagnosed from session telemetry",
                        ));
                    }
                    let agent = EvolutionAgent::new();
                    let mut details: Vec<Value> = Vec::new();
                    let mut applied = 0usize;
                    let mut pending = 0usize;
                    for m in harness_mutations {
                        // C2: the fingerprint binds the authorized CHANGE
                        // (component + patch content), not diagnostic prose —
                        // stable across re-diagnoses, so a standing approval
                        // matches.
                        let fingerprint = mutation_fingerprint(m);
                        // C1: prior decisions come from the daemon's SHARED
                        // durable ledger — the approver is typically another
                        // connection (a host UI).
                        let prior = {
                            let ledger = state.approval_ledger.read().await;
                            ledger.lookup(&fingerprint).map(|r| r.decision)
                        };
                        // Authorization resolution, most binding first: a
                        // durable human decision, then the regression gate
                        // (only when the caller measured candidate telemetry),
                        // else HITL. A pending entry is always LISTED —
                        // reporting what needs approval is response data;
                        // dry_run only skips real side effects.
                        let status: Value = match prior {
                            Some(car_policy::ApprovalDecision::Rejected) => {
                                serde_json::json!({ "status": "rejected_by_operator" })
                            }
                            Some(car_policy::ApprovalDecision::Approved) => {
                                if m.patch.is_none() {
                                    serde_json::json!({
                                        "status": "approved_no_patch",
                                        "note": "approved but carries no concrete config patch — a human designs this change",
                                    })
                                } else if dry_run {
                                    serde_json::json!({ "status": "would_apply", "governance": "human_approved" })
                                } else {
                                    // S3: one atomic read-modify-write under
                                    // the runtime's config write lock.
                                    match session
                                        .runtime
                                        .update_harness_config(|cfg| {
                                            cfg.apply(m, Governance::HumanApproved)
                                        })
                                        .await
                                    {
                                        Ok(inverse) => {
                                            applied += 1;
                                            serde_json::json!({
                                                "status": "applied",
                                                "governance": "human_approved",
                                                "rollback_patch": inverse,
                                            })
                                        }
                                        Err(e) => serde_json::json!({
                                            "status": "apply_failed", "error": e,
                                        }),
                                    }
                                }
                            }
                            None => match candidate_metrics {
                                Some(cand) => {
                                    let decision = agent.evaluate(m, baseline_metrics, cand);
                                    match &decision {
                                        PromotionDecision::Promote { reason } => {
                                            let reason = reason.clone();
                                            if dry_run {
                                                serde_json::json!({ "status": "would_apply", "governance": "promoted", "reason": reason })
                                            } else {
                                                match session
                                                    .runtime
                                                    .update_harness_config(|cfg| {
                                                        cfg.apply(
                                                            m,
                                                            Governance::Promoted(decision.clone()),
                                                        )
                                                    })
                                                    .await
                                                {
                                                    Ok(inverse) => {
                                                        applied += 1;
                                                        serde_json::json!({
                                                            "status": "applied",
                                                            "governance": "promoted",
                                                            "reason": reason,
                                                            "rollback_patch": inverse,
                                                        })
                                                    }
                                                    Err(e) => serde_json::json!({
                                                        "status": "apply_failed", "error": e,
                                                    }),
                                                }
                                            }
                                        }
                                        PromotionDecision::NeedsApproval { reason } => {
                                            pending += 1;
                                            pending_ref.lock().unwrap().push(serde_json::json!({
                                                "fingerprint": fingerprint,
                                                "mutation": m.id,
                                                "component": m.contract.component,
                                                "safety_affecting": true,
                                                "rationale": m.rationale,
                                                "reason": reason,
                                            }));
                                            serde_json::json!({ "status": "pending_approval", "reason": reason })
                                        }
                                        PromotionDecision::Reject { reason } => {
                                            serde_json::json!({ "status": "rejected_by_gate", "reason": reason })
                                        }
                                    }
                                }
                                None => {
                                    // No held-out candidate telemetry → the
                                    // regression gate cannot be run honestly
                                    // in-cycle; every activation routes to
                                    // HITL. Patchless mutations are proposals
                                    // a human addresses either way.
                                    const NO_CANDIDATE_REASON: &str =
                                        "no held-out candidate telemetry supplied — the regression gate needs measured post-mutation metrics (pass harness_candidate_metrics), so activation requires operator approval";
                                    pending += 1;
                                    pending_ref.lock().unwrap().push(serde_json::json!({
                                        "fingerprint": fingerprint,
                                        "mutation": m.id,
                                        "component": m.contract.component,
                                        "safety_affecting": m.requires_human_approval(),
                                        "rationale": m.rationale,
                                        "reason": NO_CANDIDATE_REASON,
                                    }));
                                    serde_json::json!({
                                        "status": "pending_approval",
                                        "reason": NO_CANDIDATE_REASON,
                                    })
                                }
                            },
                        };
                        let mut d = serde_json::json!({
                            "mutation": m.id,
                            "component": m.contract.component,
                            "fingerprint": fingerprint,
                            "rationale": m.rationale,
                        });
                        if let (Some(obj), Some(s)) = (d.as_object_mut(), status.as_object()) {
                            for (k, v) in s {
                                obj.insert(k.clone(), v.clone());
                            }
                        }
                        details.push(d);
                    }
                    let summary = serde_json::to_string(&serde_json::json!({
                        "mechanism": "harness_evolution",
                        "mutations": harness_mutations.len(),
                        "applied": applied,
                        "pending": pending,
                        "details": details,
                    }))
                    .map_err(|e| e.to_string())?;
                    // S2: "evolved" means a patch actually landed — an
                    // all-pending/all-rejected pass is a no-op.
                    Ok(if applied > 0 {
                        EvolutionOutcome::applied(summary)
                    } else {
                        EvolutionOutcome::no_op(summary)
                    })
                }
                EvolvableComponent::Context => Err(
                    "not_executable: context assembly has no autonomous mutation mechanism yet — \
                     compaction/eviction tuning is an operator config change (.car/config.toml \
                     token budgets)"
                        .to_string(),
                ),
                EvolvableComponent::Tools => Err(
                    "not_executable: connector health has no autonomous remediation — \
                     reconnect/re-auth via connectors.* is an operator action"
                        .to_string(),
                ),
            }
        }
    };

    let report =
        car_memgine::self_evolution::run_evolution_cycle(&components, &policy, run).await;

    if !dry_run {
        let mut data: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
        data.insert("source".into(), Value::from("evolution.run"));
        data.insert(
            "evolve_now".into(),
            serde_json::to_value(&report.plan.evolve_now).unwrap_or(Value::Null),
        );
        data.insert(
            "evolved".into(),
            serde_json::to_value(&report.evolved).unwrap_or(Value::Null),
        );
        session.runtime.log.lock().await.append(
            car_eventlog::EventKind::EvolutionTriggered,
            None,
            None,
            data,
        );
        // S1: a bound lifecycle agent's engine is daemon-persisted after a
        // mutating run, like memory.add/memory.consolidate.
        if !report.evolved.is_empty() {
            if let Some(id) = session.agent_id.lock().await.clone() {
                if let Err(e) = persist_agent_memgine(&id, &engine_arc).await {
                    tracing::warn!(agent_id = %id, error = %e,
                        "agent memgine persist after evolution.run failed; in-memory state is canonical");
                }
            }
        }
    }

    let pending = pending_approvals.into_inner().unwrap();
    let mut resp = serde_json::json!({
        "plan": report.plan,
        "steps": report.steps,
        "evolved": report.evolved,
    });
    if !pending.is_empty() {
        resp.as_object_mut()
            .unwrap()
            .insert("pending_approvals".into(), Value::Array(pending));
    }
    Ok(resp)
}

/// Repair a degraded skill on this client's session memgine.
/// Returns `{ code: "..." }` on success, `null` if the skill
/// isn't broken or repair failed.
async fn handle_skill_repair(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let name = msg
        .params
        .get("skill_name")
        .and_then(|v| v.as_str())
        .ok_or("missing 'skill_name' parameter")?;
    let mut engine = session.memgine.lock().await;
    let code = engine.repair_skill(name).await;
    Ok(match code {
        Some(c) => serde_json::json!({ "code": c }),
        None => Value::Null,
    })
}

/// Ingest distilled skills into this client's session memgine.
/// Returns the number of nodes inserted.
async fn handle_skills_ingest_distilled(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let skills: Vec<car_memgine::DistilledSkill> = serde_json::from_value(
        msg.params
            .get("skills")
            .cloned()
            .unwrap_or(msg.params.clone()),
    )
    .map_err(|e| format!("invalid skills: {}", e))?;
    let mut engine = session.memgine.lock().await;
    let nodes = engine.ingest_distilled_skills(&skills);
    Ok(serde_json::json!({ "ingested": nodes.len() }))
}

/// Run skill evolution against this session's memgine for a
/// specified domain.  Returns the resulting `DistilledSkill` array.
async fn handle_skills_evolve(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let domain = msg
        .params
        .get("domain")
        .and_then(|v| v.as_str())
        .ok_or("missing 'domain' parameter")?
        .to_string();
    let events: Vec<car_memgine::TraceEvent> = serde_json::from_value(
        msg.params
            .get("events")
            .cloned()
            .unwrap_or(Value::Array(vec![])),
    )
    .map_err(|e| format!("invalid events: {}", e))?;
    let mut engine = session.memgine.lock().await;
    let skills = engine.evolve_skills(&events, &domain).await;
    serde_json::to_value(&skills).map_err(|e| e.to_string())
}

/// List domains whose skills are underperforming on this session.
async fn handle_skills_domains_needing_evolution(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let threshold = msg
        .params
        .get("threshold")
        .and_then(|v| v.as_f64())
        .unwrap_or(0.6);
    let engine = session.memgine.lock().await;
    let domains = engine.domains_needing_evolution(threshold);
    serde_json::to_value(&domains).map_err(|e| e.to_string())
}

/// Ingest distilled/evolved skills as PROVISIONAL candidates on trial
/// (validation-gated optimization — see docs/solutions/gated-skill-optimization.md).
/// Unlike `skills.ingest_distilled`, these must prove themselves before the
/// promotion gate makes them Active. Returns `{ ingested: n }` (drops
/// already-rejected or already-trialing candidates).
async fn handle_skills_ingest_provisional(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let skills: Vec<car_memgine::DistilledSkill> = serde_json::from_value(
        msg.params
            .get("skills")
            .cloned()
            .unwrap_or(msg.params.clone()),
    )
    .map_err(|e| format!("invalid skills: {}", e))?;
    let tenant = msg.params.get("tenant").and_then(|v| v.as_str());
    let mut engine = session.memgine.lock().await;
    let ingested = engine.ingest_provisional_candidates(&skills, tenant);
    Ok(serde_json::json!({ "ingested": ingested }))
}

/// Run the skill promotion gate against this session's memgine: provisional
/// candidates with enough trial outcomes are promoted (strictly-better Wilson
/// lower bound) or rejected. Normally fires automatically in `consolidate()`;
/// this exposes a manual trigger. Returns `{ promoted: [...], rejected: [...] }`.
async fn handle_skills_gate(
    _msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let mut engine = session.memgine.lock().await;
    let (promoted, rejected) = engine.gate_skill_candidates();
    Ok(serde_json::json!({ "promoted": promoted, "rejected": rejected }))
}

/// Fetch a skill's full `SkillMeta` by key — including lifecycle `status`
/// (active/provisional), `incumbent`, `version`, and `stats`. Returns the JSON
/// SkillMeta, or `null` if no active skill node holds the key.
async fn handle_skill_meta(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let key = msg
        .params
        .get("key")
        .and_then(|v| v.as_str())
        .ok_or("missing 'key' parameter")?;
    let engine = session.memgine.lock().await;
    match engine.skill_meta(key) {
        Some(meta) => serde_json::to_value(&meta).map_err(|e| e.to_string()),
        None => Ok(Value::Null),
    }
}

/// Export a VALIDATED skill as a portable markdown document (the SkillOpt
/// best_skill.md analog). Only Active, healthy skills export; returns the
/// markdown string, or `null` if the key is absent / not exportable.
async fn handle_skill_export(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let key = msg
        .params
        .get("key")
        .and_then(|v| v.as_str())
        .ok_or("missing 'key' parameter")?;
    let engine = session.memgine.lock().await;
    Ok(engine
        .export_skill(key)
        .map(Value::String)
        .unwrap_or(Value::Null))
}

/// Import a skill from a portable markdown document (digest-verified). Ingests
/// as a fresh Active skill. Returns `{ imported: true }`, or a JSON-RPC error if
/// the document is malformed or its content digest doesn't verify.
async fn handle_skill_import(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let md = msg
        .params
        .get("markdown")
        .and_then(|v| v.as_str())
        .ok_or("missing 'markdown' parameter")?;
    let mut engine = session.memgine.lock().await;
    engine.import_skill_markdown(md)?;
    Ok(serde_json::json!({ "imported": true }))
}

/// Rerank documents against a query using a cross-encoder model.
async fn handle_rerank(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let req: car_inference::RerankRequest =
        serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {}", e))?;
    let _permit = state.admission.acquire().await;
    let result = engine.rerank(req).await.map_err(|e| e.to_string())?;
    serde_json::to_value(&result).map_err(|e| e.to_string())
}

/// Transcribe audio at the given path. The path is interpreted on
/// the daemon's filesystem, not the FFI caller's — Daemon-mode
/// callers must pass a path the daemon can read (typically a
/// shared `~/.car/...` location or stdin push via the streaming
/// API).
/// `search` — web search, fulfilled natively (Parslee-hosted when signed in,
/// else a bring-your-own Tavily key). Engine-independent; resolves the provider
/// from the environment. See `car_inference::search`.
async fn handle_search(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let req: car_inference::search::SearchRequest =
        serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
    let _permit = state.admission.acquire().await;
    let result = car_inference::search::web_search(&req.query, req.max_results)
        .await
        .map_err(|e| e.to_string())?;
    serde_json::to_value(&result).map_err(|e| e.to_string())
}

/// `web_fetch` — fetch a URL and extract readable text. Keyless; the companion
/// to `search`. See `car_inference::search::web_fetch`.
async fn handle_web_fetch(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let req: car_inference::search::FetchRequest =
        serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
    let _permit = state.admission.acquire().await;
    let result = car_inference::search::web_fetch(&req.url)
        .await
        .map_err(|e| e.to_string())?;
    serde_json::to_value(&result).map_err(|e| e.to_string())
}

async fn handle_transcribe(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    use base64::Engine as _;
    let engine = get_inference_engine(state);

    // Sandbox-crossing escape hatch (Parslee-ai/car-releases#31): when
    // the caller can't share a filesystem view with the daemon (e.g.
    // unsandboxed Milo talking to a sandboxed car-host), they pass
    // `audio_b64` instead of `audio_path`. We decode to a tempfile,
    // run transcribe against the path the engine expects, and clean up
    // on drop. Accepts either form; `audio_b64` wins if both are set.
    let mut params = msg.params.clone();
    let audio_b64 = params
        .as_object_mut()
        .and_then(|m| m.remove("audio_b64"))
        .and_then(|v| v.as_str().map(str::to_string));
    let _tmp_audio = if let Some(b64) = audio_b64 {
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(b64.as_bytes())
            .map_err(|e| format!("audio_b64 decode failed: {e}"))?;
        let tmp = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
        std::fs::write(tmp.path(), &bytes).map_err(|e| e.to_string())?;
        let path = tmp.path().to_string_lossy().into_owned();
        if let Some(obj) = params.as_object_mut() {
            obj.insert("audio_path".to_string(), Value::String(path));
        }
        Some(tmp)
    } else {
        None
    };

    let req: car_inference::TranscribeRequest =
        serde_json::from_value(params).map_err(|e| format!("invalid params: {}", e))?;
    let _permit = state.admission.acquire().await;
    let result = engine.transcribe(req).await.map_err(|e| e.to_string())?;
    serde_json::to_value(&result).map_err(|e| e.to_string())
}

/// Synthesize speech. By default writes to `output_path` on the
/// daemon's filesystem; when `return_b64: true` (or no `output_path`
/// was supplied) the result also includes an `audio_b64` field with
/// the rendered bytes inline so cross-sandbox callers can avoid
/// filesystem coordination. Closes Parslee-ai/car-releases#31.
async fn handle_synthesize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    use base64::Engine as _;
    let engine = get_inference_engine(state);

    let mut params = msg.params.clone();
    let return_b64 = params
        .as_object_mut()
        .and_then(|m| m.remove("return_b64"))
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let no_output_path = params
        .as_object()
        .map(|m| !m.contains_key("output_path"))
        .unwrap_or(true);

    let req: car_inference::SynthesizeRequest =
        serde_json::from_value(params).map_err(|e| format!("invalid params: {}", e))?;
    let _permit = state.admission.acquire().await;
    let result = engine.synthesize(req).await.map_err(|e| e.to_string())?;
    let mut value = serde_json::to_value(&result).map_err(|e| e.to_string())?;

    // Inline the bytes when the caller asked for them OR when no
    // output_path was specified (typical sandbox-crossing case —
    // they didn't pick a path because they have no shared one).
    if return_b64 || no_output_path {
        let bytes = std::fs::read(&result.audio_path).map_err(|e| {
            format!(
                "synthesize: failed to read rendered audio at {}: {e}",
                result.audio_path
            )
        })?;
        let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
        if let Some(obj) = value.as_object_mut() {
            obj.insert("audio_b64".to_string(), Value::String(encoded));
        }
    }
    Ok(value)
}

/// Prepare the speech runtime (downloads / warmup). Returns a
/// JSON status string, mirroring the embedded
/// `prepare_speech_runtime` shape.
async fn handle_speech_prepare(state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let status = engine
        .prepare_speech_runtime()
        .await
        .map_err(|e| e.to_string())?;
    serde_json::to_value(&status).map_err(|e| e.to_string())
}

/// Adaptive route decision for a prompt — returns the routing
/// JSON the FFI's `route_model` returns.
async fn handle_models_route(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
    let prompt = msg
        .params
        .get("prompt")
        .and_then(|v| v.as_str())
        .ok_or("missing 'prompt' parameter")?;
    // Optional routing intent — notably `exclude_models` for
    // adversarial-reviewer separation (car#358). Absent = no intent.
    let intent: Option<car_inference::IntentHint> = match msg.params.get("intent") {
        Some(v) if !v.is_null() => {
            Some(serde_json::from_value(v.clone()).map_err(|e| format!("invalid 'intent': {e}"))?)
        }
        _ => None,
    };
    let engine = get_inference_engine(state);
    let decision = engine.route_adaptive_with_intent(prompt, intent).await;
    serde_json::to_value(&decision).map_err(|e| e.to_string())
}

/// Model performance profiles snapshot.
async fn handle_models_stats(state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let profiles = engine.export_profiles().await;
    Ok(serde_json::json!({ "profiles": models_stats_view(&profiles) }))
}

/// `outcomes.scoreboard` — the persistent, OUTCOME-DENOMINATED scoreboard
/// folded from the durable outcome ledger: per-model cost-per-success,
/// tokens-per-success, and success-rate, plus the deployment headline
/// `overall_usd_per_success`. Unlike `models.stats` (the live in-memory
/// profiles), this reads the cross-session ledger so it survives restart and is
/// de-biased by the pending-sweep. The dollars-per-correct-outcome view the
/// "results, not KPIs" thesis is legible in.
async fn handle_outcomes_scoreboard(state: &ServerState) -> Result<Value, String> {
    let engine = get_inference_engine(state);
    let scoreboard = engine.outcome_scoreboard();
    serde_json::to_value(&scoreboard).map_err(|e| e.to_string())
}

/// Build the `models.stats` per-profile view. The raw `ModelProfile`
/// serializes counts (`success_count`/`fail_count`) but not the
/// `success_rate` / `avg_latency_ms` the tool schema promised — so the schema
/// was a contract lie (#335). Rather than drop fields (the CLI's cost join
/// needs `total_output_tokens` etc.), serialize the full profile and *add* the
/// two derived values. `success_rate` is `number | null` (`null` until
/// something resolves — never the router's 0.5 prior), consistent with the
/// `concierge.status` health contract.
fn models_stats_view(profiles: &[car_inference::ModelProfile]) -> Vec<Value> {
    profiles
        .iter()
        .map(|p| {
            let mut v = serde_json::to_value(p).expect("ModelProfile is infallibly serializable");
            if let Some(obj) = v.as_object_mut() {
                obj.insert(
                    "success_rate".into(),
                    serde_json::json!(p.success_rate_resolved()),
                );
                obj.insert(
                    "avg_latency_ms".into(),
                    serde_json::json!(p.avg_latency_ms()),
                );
            }
            v
        })
        .collect()
}

#[cfg(test)]
mod models_stats_view_tests {
    use super::models_stats_view;

    #[test]
    fn success_rate_is_null_until_resolved_then_the_real_ratio() {
        let mut unmeasured = car_inference::ModelProfile::new("m-new".into());
        unmeasured.total_calls = 5; // calls logged, nothing resolved yet

        let mut measured = car_inference::ModelProfile::new("m-seen".into());
        measured.total_calls = 4;
        measured.success_count = 3;
        measured.fail_count = 1;

        let view = models_stats_view(&[unmeasured, measured]);

        // Never-measured: explicit null, NOT a fabricated 0.5.
        assert!(view[0]["success_rate"].is_null());
        assert_eq!(view[0]["success_count"], 0);
        assert_eq!(view[0]["total_calls"], 5);

        // Measured: the real resolved ratio, and the declared field names.
        assert_eq!(view[1]["success_rate"], 0.75);
        assert_eq!(view[1]["fail_count"], 1);
        assert_eq!(view[1]["model_id"], "m-seen");
    }

    #[test]
    fn view_is_non_lossy_and_round_trips_into_model_profile() {
        // Regression: the CLI (`car models stats`) deserializes the daemon
        // payload back into `Vec<ModelProfile>` for its cost join. The derived
        // view must therefore stay a *superset* of ModelProfile — adding
        // success_rate/avg_latency_ms, never dropping fields like
        // total_output_tokens. Guards the wire shape against silent breakage.
        let mut p = car_inference::ModelProfile::new("m".into());
        p.total_calls = 2;
        p.success_count = 1;
        p.total_output_tokens = 4096;

        let view = models_stats_view(&[p]);
        assert_eq!(view[0]["avg_latency_ms"], 0.0); // derived field present
        let round: Vec<car_inference::ModelProfile> =
            serde_json::from_value(serde_json::Value::Array(view)).expect("round-trips");
        assert_eq!(round[0].total_output_tokens, 4096, "field not dropped");
        assert_eq!(round[0].model_id, "m");
    }
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OutcomesResolvePendingParams {
    /// Flat `(trace_id, success, confidence, output)` tuples from the
    /// caller. Same shape `car-reason`'s session produces from its
    /// `ActionOutcome` vector. Daemon side runs the inference rules
    /// and writes resolved outcomes back to the shared tracker.
    action_results: Vec<(String, bool, f64, String)>,
}

/// `outcomes.resolve_pending` — write inferred outcomes back to the
/// shared engine's `OutcomeTracker` (Parslee-ai/car#189 follow-up).
///
/// Symmetric to the in-process path
/// `ReasoningInferenceHandle::record_inferred_outcomes` on
/// `InferenceEngine`: takes the per-action result tuples the
/// reasoning session produces, runs
/// `OutcomeTracker::infer_outcomes_from_action_sequence` to convert
/// them into `InferredOutcome` records, and calls
/// `resolve_pending_from_signals` under the tracker write lock. The
/// learning loop that adjusts routing decisions therefore survives
/// daemon-routed reasoning runs (previously a best-effort no-op on
/// the daemon side).
///
/// Returns `{ recorded: N }` where N is the number of action results
/// the caller passed. The tracker doesn't surface how many of those
/// actually had pending entries to resolve; that count would require
/// expanding the tracker API and isn't load-bearing for any caller
/// yet.
async fn handle_outcomes_resolve_pending(
    req: &JsonRpcMessage,
    state: &ServerState,
) -> Result<Value, String> {
    let params: OutcomesResolvePendingParams =
        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
    let engine = get_inference_engine(state);
    let mut tracker = engine.outcome_tracker.write().await;
    let inferred = tracker.infer_outcomes_from_action_sequence(&params.action_results);
    tracker.resolve_pending_from_signals(inferred);
    Ok(serde_json::json!({ "recorded": params.action_results.len() }))
}

/// Per-session event log size.
async fn handle_events_count(session: &crate::session::ClientSession) -> Result<Value, String> {
    let n = session.runtime.log.lock().await.len();
    Ok(Value::from(n as u64))
}

/// `events.query` — structured audit query over the session event log
/// (EPIC G / G2). Params are a `car_eventlog::EventQuery`
/// (`kinds`/`action_id`/`proposal_id`/`since`/`until`/`data_matches`/`limit`);
/// returns the matching events, most-recent-first. Answers "who ran what tool
/// when, and which approvals applied" against the SessionScope /
/// PermissionDecision / ApprovalRecorded / action trail.
async fn handle_events_query(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let query: car_eventlog::EventQuery = if msg.params.is_null() {
        Default::default()
    } else {
        serde_json::from_value(msg.params.clone())
            .map_err(|e| format!("invalid events.query params: {e}"))?
    };
    let log = session.runtime.log.lock().await;
    let events = log.query(&query);
    Ok(serde_json::json!({
        "count": events.len(),
        "events": events,
    }))
}

/// Which NLP primitive a `nlp.*` request selects (EPIC F / F4).
enum NlpOp {
    IdentifyLanguage,
    Tokenize,
    ExtractEntities,
}

/// `nlp.identify_language` / `nlp.tokenize` / `nlp.extract_entities` — run a
/// stateless NLP primitive over `params.text` (EPIC F / F4). Backed by Apple's
/// NaturalLanguage framework on macOS and a pure-Rust fallback elsewhere, so
/// non-macOS agents get a real (if lighter) result instead of an error. Every
/// result carries `backend` (`"apple"`|`"fallback"`).
fn handle_nlp(req: &JsonRpcMessage, op: NlpOp) -> Result<Value, String> {
    let text = req
        .params
        .get("text")
        .and_then(|v| v.as_str())
        .ok_or("missing 'text'")?;
    let json = match op {
        NlpOp::IdentifyLanguage => car_ffi_common::nlp::identify_language(text),
        NlpOp::Tokenize => car_ffi_common::nlp::tokenize_words(text),
        NlpOp::ExtractEntities => car_ffi_common::nlp::extract_named_entities(text),
    }?;
    serde_json::from_str(&json).map_err(|e| format!("nlp result parse: {e}"))
}

/// `metrics.summary` — live operational rollup of the session event stream
/// (EPIC G / G1): success/error rate, cost, tokens, avg latency, approvals,
/// gate rejections, policy violations, and the per-agent cost breakdown. One
/// call renders the whole live picture for a host dashboard.
async fn handle_metrics_summary(
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let log = session.runtime.log.lock().await;
    // summarize_log (not a bare fold over events()) so `cumulative_cost_usd`
    // carries the monotonic counter that survives retention trims (G1).
    let summary = car_eventlog::summarize_log(&log);
    serde_json::to_value(&summary).map_err(|e| format!("serialize metrics.summary: {e}"))
}

/// `metrics.alerts` — evaluate the live summary against operational thresholds
/// (EPIC G / G1). Params are `AlertThresholds`
/// (`max_cost_usd`/`max_error_rate`/`max_avg_latency_ms`/`min_actions`).
/// Returns `{ summary, alerts }`; every fired alert is also written to the
/// operational log (`tracing::warn`) so an operator sees it out-of-band. A
/// synthetic cost overage fires the `cost_overage` alert.
async fn handle_metrics_alerts(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let thresholds: car_eventlog::AlertThresholds = if req.params.is_null() {
        Default::default()
    } else {
        serde_json::from_value(req.params.clone())
            .map_err(|e| format!("invalid metrics.alerts thresholds: {e}"))?
    };
    let log = session.runtime.log.lock().await;
    // The budget alert reads the monotonic cumulative cost, not a fold over
    // the retention-trimmed window — a trim must never un-fire it (G1).
    let summary = car_eventlog::summarize_log(&log);
    let alerts = car_eventlog::evaluate_alerts(&summary, &thresholds);
    for a in &alerts {
        tracing::warn!(target: "car::alerts", kind = ?a.kind, "operational alert: {}", a.message);
    }
    Ok(serde_json::json!({ "summary": summary, "alerts": alerts }))
}

/// `events.cost_by_agent` — per-agent token/cost report (EPIC G / G3),
/// folded from the session log's `InferenceMetered` events by their `agent`
/// field. Answers "how much did each agent cost this run" (e.g. Researcher
/// $2, Coordinator $0.5). Provenance (which tool/workflow) is queryable via
/// `events.query {data_matches:{agent}}`.
async fn handle_events_cost_by_agent(
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let log = session.runtime.log.lock().await;
    let report = log.cost_by_agent();
    serde_json::to_value(&report).map_err(|e| format!("serialize cost_by_agent: {e}"))
}

/// `events.retention` — get or set the session event log's auto-retention
/// policy (EPIC G / G2). With a `policy` object (`{max_events, max_age_secs}`)
/// installs it and immediately enforces the age bound, returning how many
/// events were reaped; with no `policy` returns the current policy. Bounds the
/// log by size (auto on append) and age (on this call).
async fn handle_events_retention(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let mut log = session.runtime.log.lock().await;
    if let Some(p) = msg.params.get("policy") {
        let policy: car_eventlog::RetentionPolicy = serde_json::from_value(p.clone())
            .map_err(|e| format!("invalid retention policy: {e}"))?;
        let removed = log.enforce_retention(&policy, chrono::Utc::now());
        log.set_retention(Some(policy.clone()));
        Ok(serde_json::json!({ "policy": policy, "removed": removed }))
    } else {
        Ok(serde_json::json!({ "policy": log.retention() }))
    }
}

/// `events.chain.enable` — turn on tamper-evident hash chaining for the
/// session event log (EPIC A / A9). Every event appended from now on is
/// linked to its predecessor by a content hash. Opt-in and idempotent;
/// existing events stay byte-identical.
async fn handle_events_chain_enable(
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    session.runtime.enable_event_log_hash_chaining().await;
    Ok(serde_json::json!({ "ok": true }))
}

/// `events.chain.verify` — verify the session event log's tamper-evidence
/// chain (EPIC A / A9). Returns `{verified: n}` (chained events verified) or
/// `{tampered_at: i}` naming the first event whose hash/linkage doesn't
/// match — an interior edit, deletion, or reordering. Head/tail truncation
/// is not detectable (no anchored head hash).
async fn handle_events_chain_verify(
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    match session.runtime.verify_event_log_chain().await {
        Ok(n) => Ok(serde_json::json!({ "verified": n })),
        Err(i) => Ok(serde_json::json!({ "tampered_at": i })),
    }
}

async fn handle_events_stats(session: &crate::session::ClientSession) -> Result<Value, String> {
    let stats = session.runtime.log.lock().await.stats();
    serde_json::to_value(stats).map_err(|e| e.to_string())
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct EventsTruncateParams {
    #[serde(default)]
    max_events: Option<usize>,
    #[serde(default)]
    max_spans: Option<usize>,
}

async fn handle_events_truncate(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let params: EventsTruncateParams =
        serde_json::from_value(msg.params.clone()).unwrap_or(EventsTruncateParams {
            max_events: None,
            max_spans: None,
        });
    let mut log = session.runtime.log.lock().await;
    let removed_events = params
        .max_events
        .map(|max| log.truncate_events_keep_last(max))
        .unwrap_or(0);
    let removed_spans = params
        .max_spans
        .map(|max| log.truncate_spans_keep_last(max))
        .unwrap_or(0);
    let stats = log.stats();
    Ok(serde_json::json!({
        "removedEvents": removed_events,
        "removedSpans": removed_spans,
        "stats": stats,
    }))
}

async fn handle_events_clear(session: &crate::session::ClientSession) -> Result<Value, String> {
    let mut log = session.runtime.log.lock().await;
    let removed = log.clear();
    Ok(serde_json::json!({ "removed": removed, "stats": log.stats() }))
}

// ---------------------------------------------------------------------------
// Agent run tracing — run lifecycle (U1).
//
// `runs.start` brackets the beginning of an agent run: it mints a durable
// `run_id`, resolves the owning `agent_id`, tags it as the session's
// current run BEFORE responding (so the U2 per-turn recorder always reads
// the run_id the bracket set — KTD3), records `RunStarted`, and returns
// `{ run_id, agent_id }`. `runs.complete` records the terminal
// `AgentOutcome` and acks. On a mid-run disconnect with no
// `runs.complete`, `remove_session` sweeps the run to `Incomplete` after
// a short grace window (R5) so a healthy in-flight complete is not raced.
// ---------------------------------------------------------------------------

/// Resolve the owning `agent_id` for a `runs.start` call, in priority
/// order: the connection's bound agent (`session.auth {agent_id}`), the
/// `CAR_AGENT_ID` env (supervised one-shot), then a deterministic id
/// synthesized from the supplied `agent_name` (unsupervised one-shot, so
/// `run_scenarios.py` runs still record). Returns `Err` only when none of
/// these resolve — a run with no identity has no durable key and is
/// rejected rather than recorded under an ambiguous id.
async fn resolve_run_agent_id(
    session: &crate::session::ClientSession,
    req: &car_proto::RunStartRequest,
) -> Result<String, String> {
    // 1. The connection's bound agent from `session.auth {agent_id}` is
    //    AUTHORITATIVE and wins over any caller-supplied `agent_id` (FIX 5).
    //    A bound session must not be able to record a run under a DIFFERENT
    //    agent — that's trace forgery (writing into another agent's run
    //    history). A matching explicit param is fine (redundant); a
    //    mismatching one is rejected so the forgery attempt is loud rather
    //    than silently misattributed.
    if let Some(bound) = session.agent_id.lock().await.clone() {
        if let Some(id) = req.agent_id.as_deref() {
            let id = id.trim();
            if !id.is_empty() && id != bound {
                return Err(format!(
                    "runs.start `agent_id` (`{id}`) does not match this session's bound \
                     agent: a bound connection can only record runs under its own agent"
                ));
            }
        }
        return Ok(bound);
    }
    // 2. UNBOUND session only: an explicit `agent_id` param wins. This is
    //    the one-shot path that legitimately names its agent (no binding to
    //    derive from).
    if let Some(id) = req.agent_id.as_deref() {
        let id = id.trim();
        if !id.is_empty() {
            return Ok(id.to_string());
        }
    }
    // 3. The supervisor-injected env for supervised one-shot runs.
    if let Ok(env_id) = std::env::var("CAR_AGENT_ID") {
        let env_id = env_id.trim().to_string();
        if !env_id.is_empty() {
            return Ok(env_id);
        }
    }
    // 4. Unsupervised one-shot fallback: synthesize a deterministic id
    //    from the agent's name so the run still has a stable key.
    if let Some(name) = req.agent_name.as_deref() {
        if let Some(synth) = synthesize_agent_id(name) {
            return Ok(synth);
        }
    }
    Err(
        "runs.start could not resolve an agent_id: no `agent_id` param, no bound \
         session.auth {agent_id}, no CAR_AGENT_ID env, and no usable `agent_name` \
         to synthesize one from"
            .to_string(),
    )
}

/// Deterministically derive a stable agent id from a display name for
/// the unsupervised one-shot path. Lowercases, collapses any run of
/// non-alphanumeric characters to a single `-`, trims leading/trailing
/// `-`, and prefixes `name:` so a synthesized id is recognizable as a
/// name-derived fallback (and never collides with a real supervised
/// agent id, which has no `name:` prefix). Returns `None` when the name
/// has no alphanumeric content to key on.
fn synthesize_agent_id(name: &str) -> Option<String> {
    let mut slug = String::new();
    let mut prev_dash = false;
    for ch in name.chars() {
        if ch.is_ascii_alphanumeric() {
            slug.push(ch.to_ascii_lowercase());
            prev_dash = false;
        } else if !prev_dash {
            slug.push('-');
            prev_dash = true;
        }
    }
    let slug = slug.trim_matches('-');
    if slug.is_empty() {
        return None;
    }
    Some(format!("name:{slug}"))
}

async fn handle_runs_start(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let params: car_proto::RunStartRequest = serde_json::from_value(req.params.clone())
        .map_err(|e| format!("runs.start requires {{ intent, agent_id?, agent_name?, outcome_description?, idempotency_key? }}: {e}"))?;
    if params.intent.trim().is_empty() {
        return Err("runs.start requires a non-empty `intent`".to_string());
    }

    let agent_id = resolve_run_agent_id(session, &params).await?;

    // Change B: an `idempotency_key` makes `runs.start` dedup. A re-issued
    // start with a key that already names a run — retried by a harness, or a
    // scheduler occurrence replayed on restart / another device — returns the
    // existing run instead of opening a duplicate. Check the in-memory
    // active-run map first (cheap, catches in-flight), then the persisted
    // store (catches a completed run already evicted from memory). The session
    // is re-pointed at the existing run so continued recording lands on it.
    // (Best-effort: two truly-concurrent starts with the same key can still
    // both miss — the execution lease is the exactly-once guard, not this.)
    let idempotency_key = params
        .idempotency_key
        .as_deref()
        .map(str::trim)
        .filter(|k| !k.is_empty())
        .map(str::to_string);
    if let Some(ref key) = idempotency_key {
        let existing_agent = {
            let runs = state.runs.lock().await;
            runs.get(key).map(|m| m.agent_id.clone())
        }
        .or_else(|| state.run_store.agent_for_run(key));
        if let Some(existing_agent) = existing_agent {
            *session.current_run_id.lock().await = Some(key.clone());
            return serde_json::to_value(car_proto::RunStartResponse {
                run_id: key.clone(),
                agent_id: existing_agent,
            })
            .map_err(|e| e.to_string());
        }
    }

    let run_id = idempotency_key.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
    let started_at = chrono::Utc::now();

    // KTD3: tag the session's current run BEFORE we respond and BEFORE
    // we register the run, so the moment the harness gets its ack and
    // submits a proposal, the U2 recorder reads exactly this run_id.
    *session.current_run_id.lock().await = Some(run_id.clone());

    state
        .start_run(crate::session::RunMeta {
            run_id: run_id.clone(),
            agent_id: agent_id.clone(),
            client_id: session.client_id.clone(),
            intent: params.intent.clone(),
            outcome_description: params.outcome_description.clone(),
            started_at,
            termination: None,
            ended_at: None,
            turns: Vec::new(),
        })
        .await;

    serde_json::to_value(car_proto::RunStartResponse { run_id, agent_id })
        .map_err(|e| e.to_string())
}

async fn handle_runs_complete(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let params: car_proto::RunCompleteRequest = serde_json::from_value(req.params.clone())
        .map_err(|e| format!("runs.complete requires {{ run_id, outcome }}: {e}"))?;

    let termination = car_proto::RunTermination::Outcome {
        status: params.outcome.status,
        outcome: params.outcome.clone(),
    };
    state.complete_run(&params.run_id, termination).await?;

    // Clear the session's current run when it matches — the bracket is
    // closed. A non-matching current run (the harness completing an
    // older run id) is left alone.
    {
        let mut cur = session.current_run_id.lock().await;
        if cur.as_deref() == Some(params.run_id.as_str()) {
            *cur = None;
        }
    }

    serde_json::to_value(car_proto::RunCompleteResponse {
        run_id: params.run_id,
        ok: true,
    })
    .map_err(|e| e.to_string())
}

/// Daemon-owned size invariants for `runs.record_turns` (R8a). The daemon
/// owns turn size the way it owns the turn index (`record_run_turns`,
/// session.rs FIX 6) and the way `A2uiLimits` owns surface size — it never
/// trusts the client's truncation for data that lands on disk.

/// Per-string-field byte cap. Each turn's `prompt` and `output` text and
/// any oversized `parameters` string is truncated to this with a marker
/// before the turn is appended. Sized to match U3's client-side cap with
/// headroom (the client caps at ~8 KB; the daemon's 16 KB ceiling catches
/// a misbehaving or older client without trimming healthy turns).
const RECORD_TURN_FIELD_CAP_BYTES: usize = 16 * 1024;

/// Marker appended to a daemon-truncated field so a reader can tell the
/// value was cut, not merely short.
const RECORD_TURN_TRUNC_MARKER: &str = "…[truncated]";

/// Aggregate per-turn byte cap. The per-field 16 KiB cap bounds each leaf
/// string, but a turn can carry MANY leaves (e.g. a `parameters` array of
/// thousands of sub-cap strings), so the per-field cap alone does not
/// bound the persisted JSONL line. After the typed decode each candidate
/// turn is serialized; one whose encoded form exceeds this cap gets its
/// heavy free-form fields (`parameters`, `output`, `prompt`) replaced
/// whole with [`RECORD_TURN_OVERSIZE_MARKER`] — see
/// [`enforce_turn_byte_cap`].
const RECORD_TURN_MAX_BYTES: usize = 256 * 1024;

/// Replacement value for a free-form field dropped whole by the aggregate
/// per-turn cap ([`RECORD_TURN_MAX_BYTES`]) — distinct from the per-field
/// marker so a reader can tell WHICH invariant fired.
const RECORD_TURN_OVERSIZE_MARKER: &str = "…[truncated: turn exceeded 256 KiB]";

/// Max turns in a single `runs.record_turns` batch. A larger batch is a
/// client bug (the agent flushes a bounded queue per cycle); reject it
/// loudly rather than admit an unbounded append under one lock.
const RECORD_TURNS_MAX_BATCH: usize = 256;

/// Per-run turn ceiling — a runaway-loop backstop sized well above any
/// healthy main-agent-only cycle (tens of turns), never a trimmer. This is
/// a true hard cap: a batch that would take the run PAST this many
/// recorded turns is refused whole with `dropped: "run_turn_limit"`, which
/// the agent treats as stop-sending. (Losing the straddling batch is
/// correct — at this depth the run is a runaway, not a healthy cycle.)
///
/// The AUTHORITATIVE enforcement lives in
/// [`crate::session::ServerState::record_run_turns`], under the `runs` lock,
/// because the dispatcher spawns a task per frame and the handler's
/// pre-check below reads a lock-free snapshot that pipelined batches all
/// pass before any append lands (ADV-1). The handler pre-check is kept only
/// as a fast path that avoids a doomed decode+append for the obvious
/// already-over case. We alias the single source of truth here.
const RECORD_TURNS_RUN_CEILING: usize = crate::session::RECORD_TURNS_RUN_CEILING;

/// Truncate one string in place to the daemon field-byte cap, appending
/// [`RECORD_TURN_TRUNC_MARKER`] when it was cut. Cuts on a UTF-8 char
/// boundary at or below the cap so the result is always valid.
fn truncate_turn_string(s: &mut String) {
    if s.len() > RECORD_TURN_FIELD_CAP_BYTES {
        // Find the largest char boundary ≤ the cap.
        let mut end = RECORD_TURN_FIELD_CAP_BYTES;
        while end > 0 && !s.is_char_boundary(end) {
            end -= 1;
        }
        s.truncate(end);
        s.push_str(RECORD_TURN_TRUNC_MARKER);
    }
}

/// Truncate a JSON string value in place to the daemon field-byte cap,
/// appending [`RECORD_TURN_TRUNC_MARKER`] when it was cut. Non-string
/// values are recursively walked (arrays/objects) so a large blob nested
/// inside `parameters`/`output` is still bounded; numbers/bools are left
/// as-is.
fn truncate_turn_value(value: &mut Value) {
    match value {
        Value::String(s) => truncate_turn_string(s),
        Value::Array(items) => {
            for item in items.iter_mut() {
                truncate_turn_value(item);
            }
        }
        Value::Object(map) => {
            for (_k, v) in map.iter_mut() {
                truncate_turn_value(v);
            }
        }
        _ => {}
    }
}

/// Headroom (bytes) subtracted from [`RECORD_TURN_MAX_BYTES`] when MEASURING
/// a turn, to cover the re-stamp slack (ADV-4). The cap is enforced here with
/// the turn's placeholder `index: 0`, but `record_run_turns` re-stamps the
/// index to the live append position under the `runs` lock — up to a
/// 4-digit-plus number near the run ceiling. So `"index":0` (9 bytes) can
/// grow to e.g. `"index":1999` (12 bytes): a turn measured at exactly the cap
/// would persist a few bytes OVER it. 16 bytes is comfortably above the
/// worst-case index-digit growth (the ceiling is 2000 → 4 digits → +3 bytes),
/// with margin to spare.
const RECORD_TURN_REINDEX_HEADROOM: usize = 16;

/// Effective measurement cap: a turn is treated as "within the persisted
/// cap" only when its encoded length (with the placeholder index) is at or
/// below this, leaving [`RECORD_TURN_REINDEX_HEADROOM`] for the live re-stamp.
const RECORD_TURN_MEASURE_CAP: usize = RECORD_TURN_MAX_BYTES - RECORD_TURN_REINDEX_HEADROOM;

/// Encoded size of a turn as it would persist (one JSONL line, minus the
/// trailing newline). A turn that fails to serialize reports `usize::MAX`
/// so the cap path treats it as oversized rather than waving it through.
fn turn_encoded_len(turn: &car_proto::RunTurn) -> usize {
    serde_json::to_vec(turn).map_or(usize::MAX, |v| v.len())
}

/// Enforce the aggregate per-turn byte cap ([`RECORD_TURN_MAX_BYTES`]) on
/// a decoded turn — the invariant the per-field pass cannot give: the
/// per-field cap bounds each leaf string, but not how many leaves a turn
/// carries, so a `parameters` array of thousands of sub-cap strings (or a
/// multi-MB `tool` scalar the per-field pass never visits) would still
/// produce an unbounded JSONL line.
///
/// When the encoded turn exceeds the cap, the heavy free-form fields
/// (`parameters`, `output`, `prompt`) are replaced WHOLE with
/// [`RECORD_TURN_OVERSIZE_MARKER`] (no partial salvage — at this size the
/// content is a misbehaving client, not signal). If the turn is somehow
/// STILL over cap, the remaining string scalars the per-field pass never
/// truncates (`tool`, `policy_rejected.rule`/`param`) are field-capped too.
///
/// Returns `true` when the turn is within the persisted cap (keep it) and
/// `false` when it is STILL over cap after both passes (ADV-5: reject/drop
/// it — a serialize failure or a future free-form field this pass doesn't
/// bound). The caller drops a rejected turn with a logged reason and a
/// structured drop rather than appending an over-cap line. The measurement
/// uses [`RECORD_TURN_MEASURE_CAP`] (ADV-4 re-stamp headroom).
fn enforce_turn_byte_cap(turn: &mut car_proto::RunTurn) -> bool {
    // ADV-5: exhaustively destructure so a future new `RunTurn` field is a
    // COMPILE error here, not a silently-unbounded persisted line. Every
    // field below is either a free-form string/JSON we bound, or a
    // number/C-like enum that is bounded by construction.
    let car_proto::RunTurn {
        index: _,            // usize — bounded; daemon re-stamps it anyway
        prompt: _,           // bounded below (replaced whole when oversize)
        tool: _,             // bounded below (field-capped when oversize)
        parameters: _,       // bounded below (replaced whole when oversize)
        output: _,           // bounded below (replaced whole when oversize)
        cli_outcome: _,      // C-like enum + i64 — bounded
        verifier_verdict: _, // C-like enum — bounded
        policy_rejected: _,  // bounded below (field-capped when oversize)
    } = turn;

    if turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP {
        return true;
    }
    // Replace the big three free-form fields whole.
    if !turn.parameters.is_null() {
        turn.parameters = Value::String(RECORD_TURN_OVERSIZE_MARKER.to_string());
    }
    if turn.output.is_some() {
        turn.output = Some(Value::String(RECORD_TURN_OVERSIZE_MARKER.to_string()));
    }
    if turn.prompt.is_some() {
        turn.prompt = Some(RECORD_TURN_OVERSIZE_MARKER.to_string());
    }
    if turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP {
        return true;
    }
    // Still over cap: the bytes live in the scalars the per-field pass
    // never visits. Field-cap them.
    if let Some(tool) = turn.tool.as_mut() {
        truncate_turn_string(tool);
    }
    if let Some(pr) = turn.policy_rejected.as_mut() {
        truncate_turn_string(&mut pr.rule);
        if let Some(param) = pr.param.as_mut() {
            truncate_turn_string(param);
        }
    }
    // ADV-5: if the turn is STILL over cap, every free-form field has already
    // been replaced/capped, so the only ways to land here are a serialize
    // failure (`turn_encoded_len` → usize::MAX) or a future free-form field
    // the exhaustive destructure above will have forced us to handle. Reject
    // the turn (hard, not a release-compiled-out `debug_assert`) so an
    // unbounded line never reaches disk.
    turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP
}

/// Build a non-fatal `runs.record_turns` rejection: `ok: false`, nothing
/// appended, with the machine-readable `dropped` reason the agent treats
/// as "stop sending for this run".
fn record_turns_dropped(run_id: &str, reason: &str) -> Result<Value, String> {
    serde_json::to_value(car_proto::RunRecordTurnsResponse {
        run_id: run_id.to_string(),
        base_index: 0,
        count: 0,
        ok: false,
        dropped: Some(reason.to_string()),
    })
    .map_err(|e| e.to_string())
}

/// `runs.record_turns {run_id, turns}` — WS-only batch append of
/// client-narrated turns (feedback-agent A2UI/runs plan, U1).
///
/// The turn source is an out-of-pipeline agent whose work happens inside
/// its own subprocess (e.g. a `claude -p` resolver), invisible to the
/// proposal-path recorder. It builds full `RunTurn`s itself and pushes
/// them here in batches; the daemon appends them through the SAME
/// [`ServerState::record_run_turns`] the proposal recorder uses, so the
/// index re-stamp (FIX 6), JSONL persist, and `runs.trace.event` fanout
/// are byte-identical — this handler never re-implements locking, append,
/// or fanout.
///
/// Authorization (KTD): write access is the OWNING-agent binding only. The
/// read path's host-token gate must NOT apply here — a host-token or
/// unbound session writing another agent's turns is trace forgery. An
/// unknown run AND an unauthorized run collapse to the same uniform
/// `dropped: "run_not_found"` (mirroring `handle_runs_subscribe`'s FIX 3
/// not-found), so the response is never an existence/owner oracle.
///
/// Daemon-owned size invariants (R8a): per-field truncation, an aggregate
/// per-turn byte cap ([`enforce_turn_byte_cap`]), a batch-size cap (hard
/// error above), and a per-run turn ceiling (`run_turn_limit` — a hard
/// cap: no batch is accepted that would take the run past the ceiling).
/// Run existence/terminality is pre-checked to return a distinguishable
/// `ok: false` + reason rather than `record_run_turns`'s silent
/// zero-count drop; the benign TOCTOU (a run going terminal between the
/// check and the append) still drops silently inside `record_run_turns`,
/// matching existing recorder semantics.
async fn handle_runs_record_turns(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    // Parse leniently: the wire `RunTurn` may omit `verifier_verdict`
    // (client-narrated turns default it to `not_run`) and carries a
    // client-supplied `index` we ignore. We deserialize each turn from a
    // generic object so we can inject the verdict default before the typed
    // `RunTurn` deserialize, without adding a serde default to the shared
    // proto type (the proposal recorder always sets the field).
    let raw_run_id = req
        .params
        .get("run_id")
        .and_then(Value::as_str)
        .map(str::to_string);
    let run_id = match raw_run_id {
        Some(id) if !id.trim().is_empty() => id,
        _ => return Err("runs.record_turns requires { run_id, turns: [RunTurn] }".to_string()),
    };

    let raw_turns =
        match req.params.get("turns").and_then(Value::as_array) {
            Some(arr) => arr,
            None => return Err(
                "runs.record_turns requires { run_id, turns: [RunTurn] }: `turns` must be an array"
                    .to_string(),
            ),
        };
    if raw_turns.is_empty() {
        return Err("runs.record_turns requires a non-empty `turns` array".to_string());
    }
    if raw_turns.len() > RECORD_TURNS_MAX_BATCH {
        return Err(format!(
            "runs.record_turns batch too large: {} turns exceeds the per-call cap of {}",
            raw_turns.len(),
            RECORD_TURNS_MAX_BATCH
        ));
    }

    // Decode each wire turn into a typed RunTurn, defaulting an absent
    // `verifier_verdict` to `not_run` and truncating its oversized string
    // fields to the daemon cap (R8a) — the daemon does not trust client
    // truncation for data that lands on disk.
    let mut turns: Vec<car_proto::RunTurn> = Vec::with_capacity(raw_turns.len());
    for (i, raw) in raw_turns.iter().enumerate() {
        let mut obj = raw.clone();
        match obj.as_object_mut() {
            Some(map) => {
                // The daemon owns the index (re-stamped under the `runs`
                // lock in `record_run_turns`), so a client-narrated turn's
                // `index` is genuinely IGNORED — the protocol doc says so.
                // ADV-6: overwrite it UNCONDITIONALLY with a placeholder `0`
                // (not `entry().or_insert`), so a present-but-invalid value
                // (`-1`, `"5"`, `1.5`) can't fail the typed `usize` decode and
                // reject the whole batch. The placeholder is re-stamped to the
                // live append position on append regardless.
                map.insert("index".to_string(), Value::Number(0.into()));
                map.entry("verifier_verdict")
                    .or_insert_with(|| Value::String("not_run".to_string()));
                // Truncate the heavy free-text / blob fields before the
                // typed decode so the bounded values are what gets stored.
                if let Some(p) = map.get_mut("prompt") {
                    truncate_turn_value(p);
                }
                if let Some(o) = map.get_mut("output") {
                    truncate_turn_value(o);
                }
                if let Some(params) = map.get_mut("parameters") {
                    truncate_turn_value(params);
                }
            }
            None => {
                return Err(format!(
                    "runs.record_turns requires {{ run_id, turns: [RunTurn] }}: \
                     turn {i} is not an object"
                ));
            }
        }
        let mut turn: car_proto::RunTurn = serde_json::from_value(obj).map_err(|e| {
            format!("runs.record_turns requires {{ run_id, turns: [RunTurn] }}: turn {i}: {e}")
        })?;
        // Aggregate cap (R8a): the per-field pass bounds each leaf, not the
        // sum of leaves — bound the whole encoded turn before it is stored.
        // ADV-5: a turn that is STILL over cap after the replace+field-cap
        // pass (a serialize failure, or a future free-form field) is REJECTED,
        // not waved through. Drop the whole batch with a structured reason and
        // log it server-side — an unbounded line must never reach disk.
        if !enforce_turn_byte_cap(&mut turn) {
            tracing::warn!(
                run_id = %run_id,
                turn = i,
                "runs.record_turns: turn could not be bounded under the per-turn byte cap; dropping batch"
            );
            return record_turns_dropped(&run_id, "turn_too_large");
        }
        turns.push(turn);
    }

    // Resolve the run's owning agent: in-memory registry (live run) first,
    // disk fallback for a run whose RunMeta isn't in this process — the
    // `handle_runs_subscribe` pattern. Unknown run → uniform not-found.
    //
    // ADV-2: read the run HEADER only — `(agent_id, terminal, turn_count)` —
    // not the whole `RunMeta`. The previous `run_meta` clone copied the
    // entire `turns` buffer (full prompts + CLI output) under the global
    // `runs` lock on every batch RPC; the handler needs only those three
    // facts.
    let header = state.run_header(&run_id).await;
    let owning_agent = match &header {
        Some((agent_id, _terminal, _len)) => agent_id.clone(),
        None => match state.run_store.agent_for_run(&run_id) {
            Some(a) => a,
            // FIX 3 uniform not-found: an unknown run and an unauthorized
            // run are indistinguishable to the caller.
            None => return record_turns_dropped(&run_id, "run_not_found"),
        },
    };

    // WRITE authz (KTD): owning-agent binding ONLY. A host-token or unbound
    // session must NOT be able to write another agent's turns — that is
    // forgery, the inverse of the read path's host-token allowance. An
    // unauthorized write collapses to the SAME `run_not_found` as an
    // unknown run (no existence/owner oracle); logged server-side only.
    let bound = session.agent_id.lock().await.clone();
    if bound.as_deref() != Some(owning_agent.as_str()) {
        tracing::debug!(
            run_id = %run_id,
            owning_agent = %owning_agent,
            client_id = %session.client_id,
            "runs.record_turns denied (not the owning agent); returning uniform not-found"
        );
        return record_turns_dropped(&run_id, "run_not_found");
    }

    // Pre-check terminality and the ceiling as a FAST PATH only. These read
    // the lock-free `run_header` snapshot, so a write against an obviously
    // closed / already-over-ceiling run skips the decode+append. They are
    // NOT authoritative: the dispatcher spawns a task per frame, so pipelined
    // batches can each pass this snapshot before any of them appends (ADV-1).
    // The authoritative terminal+ceiling enforcement is under the `runs` lock
    // inside `record_run_turns`, whose `RecordRunTurnsOutcome` we map below.
    if let Some((_agent_id, terminal, len)) = &header {
        if *terminal {
            return record_turns_dropped(&run_id, "run_terminal");
        }
        // Per-run turn ceiling (R8a) fast path: a runaway-loop backstop and a
        // TRUE hard cap — refuse any batch that would take the run PAST the
        // ceiling, whole, and tell the agent to stop sending. (Healthy
        // cycles are tens of turns; losing the straddling batch of a
        // 2000-turn runaway is correct, not data loss.) The under-lock check
        // in `record_run_turns` is the one that actually bounds the run; this
        // only short-circuits the common already-over case.
        if *len + turns.len() > RECORD_TURNS_RUN_CEILING {
            return record_turns_dropped(&run_id, "run_turn_limit");
        }
    }

    // Append through the shared path — index re-stamp, JSONL persist,
    // `runs.trace.event` fanout, AND the authoritative under-lock ceiling all
    // happen inside `record_run_turns` under its own lock discipline. It
    // returns a `RecordRunTurnsOutcome` that distinguishes a healthy append
    // from a ceiling refusal and from an unknown/terminal run (ADV-1) — we
    // must NOT let an under-lock ceiling refusal masquerade as `run_terminal`.
    let count = turns.len();
    let records: Vec<car_proto::RunRecord> =
        turns.into_iter().map(car_proto::RunRecord::Turn).collect();
    let new_total = match state.record_run_turns(&run_id, records).await {
        crate::session::RecordRunTurnsOutcome::Appended { new_total } => new_total,
        crate::session::RecordRunTurnsOutcome::RefusedCeiling => {
            // A pipelined batch crossed the ceiling under the lock even
            // though our fast-path snapshot was sub-ceiling — the runaway
            // backstop fired authoritatively. Tell the agent to stop.
            return record_turns_dropped(&run_id, "run_turn_limit");
        }
        crate::session::RecordRunTurnsOutcome::UnknownOrTerminal => {
            // `record_run_turns` appended nothing because the run is unknown
            // or went terminal. We resolved the run above, so the benign
            // cause is the TOCTOU: the run went terminal between our
            // fast-path check and the append. Surface a non-fatal drop.
            return record_turns_dropped(&run_id, "run_terminal");
        }
    };

    serde_json::to_value(car_proto::RunRecordTurnsResponse {
        run_id,
        base_index: new_total - count,
        count,
        ok: true,
        dropped: None,
    })
    .map_err(|e| e.to_string())
}

/// Authorize the calling connection to read/subscribe a run owned by
/// `agent_id` (R16 / KTD10 / Parslee-ai/car#254). A connection is
/// entitled when:
///
/// 1. It **owns** the agent — its `session.auth {agent_id}` binding
///    matches the run's owning `agent_id` (the supervised agent reading
///    its own trace), or
/// 2. It holds the **host-management role** — it authenticated via
///    `session.auth { host_token }` with the per-launch host token
///    (`ClientSession::is_host`).
///
/// What this gate buys, and its bound (be honest):
///
/// - It distinguishes agent-bound sessions, so one supervised agent's
///   connection cannot read a *different* agent's runs by guessing ids.
/// - Host-role is gated by the **host token**, NOT by `host.subscribe`
///   membership. This is the #254 fix: `host.subscribe` has no authz, so
///   keying on `is_subscribed` let ANY authenticated connection
///   self-elevate and read every agent's traces. The host token is read
///   only from the `0600` `host-token` file (never served over
///   `GET /auth-token`), so a different local user — or a client that
///   scraped the auth token off the HTTP endpoint — cannot obtain it.
/// - It is therefore a real confidentiality boundary against *another
///   local user* (multi-user / CI / remote daemon). It is NOT an
///   isolation boundary against a *malicious same-user process*: that
///   process can read the `0600` host-token (and the `0600` run files)
///   directly. Closing same-user isolation would need DPAPI/Keychain or
///   process-cred (SO_PEERCRED) auth — tracked separately, out of scope.
///
/// NOTE: lower-sensitivity host *metadata* — the agent roster, approvals,
/// and host events delivered by `host.subscribe` / `host.agents` /
/// `host.approvals` — is intentionally still available to any
/// authenticated connection (the local UI consumes it). Only run-trace
/// *content* (prompts, CLI output) requires host-role. Tightening that
/// metadata surface is a separate decision from #254.
async fn authorize_run_access(
    session: &crate::session::ClientSession,
    _state: &Arc<ServerState>,
    owning_agent_id: &str,
) -> Result<(), String> {
    // 1. The connection owns the agent (its own run).
    if let Some(bound) = session.agent_id.lock().await.clone() {
        if bound == owning_agent_id {
            return Ok(());
        }
    }
    // 2. The connection authenticated as the host-management client by
    //    presenting the per-launch host token (Parslee-ai/car#254).
    //    NOTE: this is deliberately NOT `host.is_subscribed(...)` — that
    //    check let any authenticated connection self-elevate just by
    //    calling `host.subscribe` (which has no authz), reading every
    //    agent's run traces. host.subscribe still works for host *events*;
    //    it just no longer grants the cross-agent run-trace read.
    if session.is_host.load(std::sync::atomic::Ordering::Acquire) {
        return Ok(());
    }
    // Do NOT name the owning `agent_id` in the error (FIX 3): a caller-
    // supplied or resolved id is not a transparent key, and echoing it back
    // turns a rejection into an existence/owner oracle. Log it server-side
    // for diagnosis; keep the wire message agent-agnostic.
    tracing::debug!(
        owning_agent = %owning_agent_id,
        client_id = %session.client_id,
        "run access denied: connection neither owns the agent nor is the host client"
    );
    Err(
        "not authorized to access this run: this connection neither owns the \
         owning agent (via session.auth) nor is the host management client"
            .to_string(),
    )
}

/// `runs.subscribe {run_id}` — register for the run's live
/// `runs.trace.event` stream and return a snapshot + cursor (U4).
///
/// Authorizes the caller against the run's owning `agent_id` (R16), then
/// atomically snapshots the run's turns and registers the subscriber
/// under the `runs` lock (invariant #1, in [`ServerState::subscribe_run`]).
///
/// FIX 3 (existence/owner oracle): an unknown `run_id` and an
/// exists-but-unauthorized `run_id` must be INDISTINGUISHABLE. Both return
/// the same uniform not-found marker (`{ run_id, not_found: true }`,
/// mirroring [`handle_runs_get_trace`]) — never an error frame that varies
/// by case, and never the owning `agent_id`. Otherwise an unentitled caller
/// could probe which `run_id`s exist (and learn the owning agent) by
/// telling "unknown" apart from "not authorized".
async fn handle_runs_subscribe(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let params: car_proto::RunSubscribeRequest = serde_json::from_value(req.params.clone())
        .map_err(|e| format!("runs.subscribe requires {{ run_id }}: {e}"))?;

    // Uniform not-found marker shared by the unknown-run and unauthorized
    // cases so the two are indistinguishable to the caller (FIX 3).
    let not_found = || {
        serde_json::to_value(serde_json::json!({
            "run_id": params.run_id,
            "not_found": true,
        }))
        .map_err(|e| e.to_string())
    };

    // Resolve the run's owning agent_id. Prefer the in-memory registry
    // (live run); fall back to the disk store (a run that exists but whose
    // RunMeta isn't in this process). Unknown run → uniform not-found.
    let owning_agent = match state.run_meta(&params.run_id).await {
        Some(meta) => meta.agent_id,
        None => match state.run_store.agent_for_run(&params.run_id) {
            Some(a) => a,
            None => return not_found(),
        },
    };

    // R16/KTD10: gate before snapshotting. An unauthorized caller gets the
    // SAME uniform not-found as an unknown run — no distinguishable outcome,
    // no leaked owning `agent_id` (logged server-side only).
    if authorize_run_access(session, state, &owning_agent)
        .await
        .is_err()
    {
        tracing::debug!(
            run_id = %params.run_id,
            owning_agent = %owning_agent,
            client_id = %session.client_id,
            "runs.subscribe denied (unauthorized); returning uniform not-found"
        );
        return not_found();
    }

    let snapshot = match state
        .subscribe_run(&params.run_id, &session.client_id, session.channel.clone())
        .await
    {
        Some(s) => s,
        // Raced away between resolution and subscribe — still uniform.
        None => return not_found(),
    };

    serde_json::to_value(snapshot).map_err(|e| e.to_string())
}

/// `runs.unsubscribe {run_id}` — drop this connection's live run-trace
/// subscription for `run_id` (U4). Idempotent: returns `removed: false`
/// if there was nothing to remove. No authorization gate is needed —
/// removing your own subscription leaks nothing.
async fn handle_runs_unsubscribe(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let params: car_proto::RunUnsubscribeRequest = serde_json::from_value(req.params.clone())
        .map_err(|e| format!("runs.unsubscribe requires {{ run_id }}: {e}"))?;
    let removed = state
        .unsubscribe_run(&params.run_id, &session.client_id)
        .await;
    serde_json::to_value(car_proto::RunUnsubscribeResponse {
        run_id: params.run_id,
        removed,
    })
    .map_err(|e| e.to_string())
}

/// `runs.list {agent_id}` — list an agent's runs newest-first for replay
/// (U5). Reads the disk store ([`RunStore::list_runs`]), so it works
/// across daemon restart / `client_id` churn — `agent_id` is the durable
/// key, not the connection.
///
/// R16/KTD10: the `agent_id` is **not** a transparent key. We authorize
/// the caller for it FIRST — it must own the agent (`session.auth
/// {agent_id}`) or be the CarHost host-client — so an unentitled caller
/// can't enumerate other agents' run lists. An authorized caller for an
/// agent with no runs gets an empty list (the empty state, R13).
async fn handle_runs_list(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let params: car_proto::RunListRequest = serde_json::from_value(req.params.clone())
        .map_err(|e| format!("runs.list requires {{ agent_id }}: {e}"))?;

    // Gate before reading — the param is an authorization subject, not a
    // lookup key. An unentitled caller is rejected, never served a list.
    authorize_run_access(session, state, &params.agent_id).await?;

    let runs = state.run_store.list_runs(&params.agent_id);
    serde_json::to_value(serde_json::json!({
        "agent_id": params.agent_id,
        "runs": runs,
    }))
    .map_err(|e| e.to_string())
}

/// `runs.get_trace {run_id, cursor?}` — fetch a run's full ordered trace
/// from the disk store for replay (U5). Like `runs.subscribe`, this reads
/// persisted records ([`RunStore::get_run_trace`]), so a completed run
/// replays identically to what the live stream delivered, and it works on
/// a freshly-restarted daemon with empty memory.
///
/// R16/KTD10: resolve the run's owning `agent_id` from disk and authorize
/// the caller against it before serving any record — a `run_id` is not a
/// transparent key. An unknown `run_id` (no file on disk) returns a clear
/// not-found marker (`{ run_id, not_found: true }`), not an error frame,
/// so a UI can distinguish "no such run" from a transport failure.
///
/// A `timeout`/`Incomplete` run is **not** an error: its persisted trail
/// (the turns recorded so far plus the terminal `Ended`/`Incomplete`
/// marker) is returned, so the dashboard renders the partial run.
async fn handle_runs_get_trace(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let params: car_proto::RunGetTraceRequest = serde_json::from_value(req.params.clone())
        .map_err(|e| format!("runs.get_trace requires {{ run_id, cursor? }}: {e}"))?;

    // Resolve the owning agent from disk (the durable key path — works
    // after a restart). An unknown run_id has no owner: not-found, and we
    // never reach the store read. We don't reveal whether the id exists to
    // an unauthorized caller — authorization is checked against the
    // resolved owner, and resolution failure is the same not-found either
    // way.
    let Some(owning_agent) = state.run_store.agent_for_run(&params.run_id) else {
        return serde_json::to_value(serde_json::json!({
            "run_id": params.run_id,
            "not_found": true,
        }))
        .map_err(|e| e.to_string());
    };

    // R16/KTD10: gate before serving any record.
    authorize_run_access(session, state, &owning_agent).await?;

    // Load the full ordered trail from disk. `agent_for_run` just resolved
    // the file, so this is the cheaper keyed read.
    let records = state
        .run_store
        .get_run_trace_for(&owning_agent, &params.run_id)
        .unwrap_or_default();

    // Optional paging: a non-zero cursor starts the returned slice at that
    // index into the ordered record stream. The response echoes the
    // applied cursor so the client knows the offset. An out-of-range
    // cursor yields an empty slice (the client has the whole run already).
    let cursor = params.cursor.unwrap_or(0);
    let paged: Vec<car_proto::RunRecord> = if cursor == 0 {
        records
    } else {
        records.into_iter().skip(cursor).collect()
    };

    serde_json::to_value(serde_json::json!({
        "run_id": params.run_id,
        "agent_id": owning_agent,
        "records": paged,
        "cursor": cursor,
    }))
    .map_err(|e| e.to_string())
}

/// Update the per-session replan config. Wire shape mirrors the
/// FFI's positional `set_replan_config` arguments — the engine
/// crate's `ReplanConfig` struct doesn't derive Serialize, so we
/// reconstruct it from a flat object here.
async fn handle_replan_set_config(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let max_replans = msg
        .params
        .get("max_replans")
        .and_then(|v| v.as_u64())
        .unwrap_or(0) as u32;
    let delay_ms = msg
        .params
        .get("delay_ms")
        .and_then(|v| v.as_u64())
        .unwrap_or(0);
    let verify_before_execute = msg
        .params
        .get("verify_before_execute")
        .and_then(|v| v.as_bool())
        .unwrap_or(true);
    let replan_on_rejected = msg
        .params
        .get("replan_on_rejected")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let cfg = car_engine::ReplanConfig {
        max_replans,
        delay_ms,
        verify_before_execute,
        replan_on_rejected,
    };
    session.runtime.set_replan_config(cfg).await;
    Ok(Value::Null)
}

async fn handle_skills_list(
    msg: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let domain = msg.params.get("domain").and_then(|v| v.as_str());
    let engine = session.memgine.lock().await;
    let skills: Vec<serde_json::Value> = engine
        .graph
        .inner
        .node_indices()
        .filter_map(|nix| {
            let node = engine.graph.inner.node_weight(nix)?;
            if node.kind != car_memgine::MemKind::Skill {
                return None;
            }
            let meta = car_memgine::SkillMeta::from_node(node)?;
            if let Some(d) = domain {
                match &meta.scope {
                    car_memgine::SkillScope::Global => {}
                    car_memgine::SkillScope::Domain(sd) if sd == d => {}
                    _ => return None,
                }
            }
            Some(serde_json::to_value(&meta).unwrap_or_default())
        })
        .collect();
    serde_json::to_value(&skills).map_err(|e| e.to_string())
}

#[derive(serde::Deserialize)]
struct SecretParams {
    #[serde(default)]
    service: Option<String>,
    key: String,
    #[serde(default)]
    value: Option<String>,
}

fn handle_secret_put(req: &JsonRpcMessage) -> Result<Value, String> {
    let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let value = p.value.ok_or_else(|| "missing 'value'".to_string())?;
    car_ffi_common::secrets::put(p.service.as_deref(), &p.key, &value)
}

fn handle_secret_get(req: &JsonRpcMessage) -> Result<Value, String> {
    let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    car_ffi_common::secrets::get(p.service.as_deref(), &p.key)
}

fn handle_secret_delete(req: &JsonRpcMessage) -> Result<Value, String> {
    let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    car_ffi_common::secrets::delete(p.service.as_deref(), &p.key)
}

fn handle_secret_status(req: &JsonRpcMessage) -> Result<Value, String> {
    let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    car_ffi_common::secrets::status(p.service.as_deref(), &p.key)
}

#[derive(serde::Deserialize)]
struct PermParams {
    domain: String,
    #[serde(default)]
    target_bundle_id: Option<String>,
}

fn handle_perm_status(req: &JsonRpcMessage) -> Result<Value, String> {
    let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    car_ffi_common::permissions::status(&p.domain, p.target_bundle_id.as_deref())
}

fn handle_perm_request(req: &JsonRpcMessage) -> Result<Value, String> {
    let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    car_ffi_common::permissions::request(&p.domain, p.target_bundle_id.as_deref())
}

fn handle_perm_explain(req: &JsonRpcMessage) -> Result<Value, String> {
    let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    car_ffi_common::permissions::explain(&p.domain, p.target_bundle_id.as_deref())
}

fn handle_calendar_events(req: &JsonRpcMessage) -> Result<Value, String> {
    #[derive(serde::Deserialize)]
    struct P {
        start: String,
        end: String,
        #[serde(default)]
        calendar_ids: Vec<String>,
    }
    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let start = chrono::DateTime::parse_from_rfc3339(&p.start)
        .map_err(|e| format!("parse start: {}", e))?
        .with_timezone(&chrono::Utc);
    let end = chrono::DateTime::parse_from_rfc3339(&p.end)
        .map_err(|e| format!("parse end: {}", e))?
        .with_timezone(&chrono::Utc);
    car_ffi_common::integrations::calendar_events(start, end, &p.calendar_ids)
}

fn handle_calendar_create_event(req: &JsonRpcMessage) -> Result<Value, String> {
    let raw = req.params.to_string();
    car_ffi_common::integrations::calendar_create_event(&raw)
}

fn handle_calendar_update_event(req: &JsonRpcMessage) -> Result<Value, String> {
    let raw = req.params.to_string();
    car_ffi_common::integrations::calendar_update_event(&raw)
}

fn handle_calendar_delete_event(req: &JsonRpcMessage) -> Result<Value, String> {
    #[derive(serde::Deserialize)]
    struct P {
        event_id: String,
    }
    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    car_ffi_common::integrations::calendar_delete_event(&p.event_id)
}

fn handle_contacts_find(req: &JsonRpcMessage) -> Result<Value, String> {
    #[derive(serde::Deserialize)]
    struct P {
        query: String,
        #[serde(default = "default_limit")]
        limit: usize,
        #[serde(default)]
        container_ids: Vec<String>,
    }
    fn default_limit() -> usize {
        50
    }
    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    car_ffi_common::integrations::contacts_list(&p.query, &p.container_ids, p.limit)
}

fn handle_mail_inbox(req: &JsonRpcMessage) -> Result<Value, String> {
    #[derive(serde::Deserialize, Default)]
    struct P {
        #[serde(default)]
        account_ids: Vec<String>,
    }
    let p: P = serde_json::from_value(req.params.clone()).unwrap_or_default();
    car_ffi_common::integrations::mail_inbox(&p.account_ids)
}

fn handle_mail_send(req: &JsonRpcMessage) -> Result<Value, String> {
    let raw = req.params.to_string();
    car_ffi_common::integrations::mail_send(&raw)
}

fn handle_messages_chats(req: &JsonRpcMessage) -> Result<Value, String> {
    #[derive(serde::Deserialize)]
    struct P {
        #[serde(default = "default_limit")]
        limit: usize,
    }
    fn default_limit() -> usize {
        50
    }
    let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 50 });
    car_ffi_common::integrations::messages_chats(p.limit)
}

fn handle_messages_send(req: &JsonRpcMessage) -> Result<Value, String> {
    let raw = req.params.to_string();
    car_ffi_common::integrations::messages_send(&raw)
}

fn handle_notes_find(req: &JsonRpcMessage) -> Result<Value, String> {
    #[derive(serde::Deserialize)]
    struct P {
        query: String,
        #[serde(default = "default_limit")]
        limit: usize,
    }
    fn default_limit() -> usize {
        50
    }
    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    car_ffi_common::integrations::notes_find(&p.query, p.limit)
}

fn handle_reminders_items(req: &JsonRpcMessage) -> Result<Value, String> {
    #[derive(serde::Deserialize)]
    struct P {
        #[serde(default = "default_limit")]
        limit: usize,
    }
    fn default_limit() -> usize {
        50
    }
    let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 50 });
    car_ffi_common::integrations::reminders_items(p.limit)
}

fn handle_bookmarks_list(req: &JsonRpcMessage) -> Result<Value, String> {
    #[derive(serde::Deserialize)]
    struct P {
        #[serde(default = "default_limit")]
        limit: usize,
    }
    fn default_limit() -> usize {
        100
    }
    let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 100 });
    car_ffi_common::integrations::bookmarks_list(p.limit)
}

fn handle_health_sleep(req: &JsonRpcMessage) -> Result<Value, String> {
    #[derive(serde::Deserialize)]
    struct P {
        start: String,
        end: String,
    }
    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let s = chrono::DateTime::parse_from_rfc3339(&p.start)
        .map_err(|e| format!("parse start: {}", e))?
        .with_timezone(&chrono::Utc);
    let e = chrono::DateTime::parse_from_rfc3339(&p.end)
        .map_err(|e| format!("parse end: {}", e))?
        .with_timezone(&chrono::Utc);
    car_ffi_common::health::sleep_windows(s, e)
}

fn handle_health_workouts(req: &JsonRpcMessage) -> Result<Value, String> {
    #[derive(serde::Deserialize)]
    struct P {
        start: String,
        end: String,
    }
    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let s = chrono::DateTime::parse_from_rfc3339(&p.start)
        .map_err(|e| format!("parse start: {}", e))?
        .with_timezone(&chrono::Utc);
    let e = chrono::DateTime::parse_from_rfc3339(&p.end)
        .map_err(|e| format!("parse end: {}", e))?
        .with_timezone(&chrono::Utc);
    car_ffi_common::health::workouts(s, e)
}

fn handle_health_activity(req: &JsonRpcMessage) -> Result<Value, String> {
    #[derive(serde::Deserialize)]
    struct P {
        start: String,
        end: String,
    }
    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let s = chrono::NaiveDate::parse_from_str(&p.start, "%Y-%m-%d")
        .map_err(|e| format!("parse start: {}", e))?;
    let e = chrono::NaiveDate::parse_from_str(&p.end, "%Y-%m-%d")
        .map_err(|e| format!("parse end: {}", e))?;
    car_ffi_common::health::activity(s, e)
}

async fn handle_browser_close(session: &crate::session::ClientSession) -> Result<Value, String> {
    let closed = session.browser.close().await?;
    Ok(serde_json::json!({"closed": closed}))
}

async fn handle_browser_run(
    req: &JsonRpcMessage,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    #[derive(serde::Deserialize)]
    struct BrowserRunParams {
        /// Inline JSON string (CLI-compatible), OR the structured object.
        script: Value,
        #[serde(default)]
        width: Option<u32>,
        #[serde(default)]
        height: Option<u32>,
        /// When true, launches a visible Chromium window for interactive
        /// flows (first-time auth, 2FA, supervised runs). Only honored on
        /// the call that first launches the browser session — subsequent
        /// calls reuse the existing browser regardless.
        #[serde(default)]
        headed: Option<bool>,
        /// Extra Chromium command-line flags appended verbatim at
        /// launch (#112). Honoured only on the launch call.
        #[serde(default)]
        extra_args: Option<Vec<String>>,
    }
    let params: BrowserRunParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;

    // Accept either a JSON string OR a structured object under `script`.
    let script_json = match params.script {
        Value::String(s) => s,
        other => other.to_string(),
    };

    let browser_session = session
        .browser
        .get_or_launch(car_ffi_common::browser::BrowserLaunchOptions {
            width: params.width.unwrap_or(1280),
            height: params.height.unwrap_or(720),
            headless: !params.headed.unwrap_or(false),
            extra_args: params.extra_args.unwrap_or_default(),
        })
        .await?;

    let trace_json = browser_session.run(&script_json).await?;
    serde_json::from_str(&trace_json).map_err(|e| e.to_string())
}

// ---------------------------------------------------------------------------
// Voice streaming JSON-RPC methods
//
// Events are pushed back to the originating client as JSON-RPC notifications:
//   { "jsonrpc": "2.0", "method": "voice.event",
//     "params": { "session_id": "...", "event": {...} } }
//
// The session registry is process-wide (ServerState.voice_sessions); per-call
// WsVoiceEventSink instances bind each session to its originating WS so a
// client only ever sees events for sessions it started.
// ---------------------------------------------------------------------------

#[derive(Deserialize)]
struct VoiceStartParams {
    session_id: String,
    audio_source: Value,
    #[serde(default)]
    options: Option<Value>,
}

async fn handle_voice_transcribe_stream_start(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
    session: &Arc<crate::session::ClientSession>,
) -> Result<Value, String> {
    let params: VoiceStartParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let audio_source_json =
        serde_json::to_string(&params.audio_source).map_err(|e| e.to_string())?;
    let options_json = params
        .options
        .as_ref()
        .map(|v| serde_json::to_string(v).map_err(|e| e.to_string()))
        .transpose()?;
    let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
        channel: session.channel.clone(),
    });
    let json = car_ffi_common::voice::transcribe_stream_start(
        &params.session_id,
        &audio_source_json,
        options_json.as_deref(),
        state.voice_sessions.clone(),
        sink,
    )
    .await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

#[derive(Deserialize)]
struct VoiceStopParams {
    session_id: String,
}

async fn handle_voice_transcribe_stream_stop(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let params: VoiceStopParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let json = car_ffi_common::voice::transcribe_stream_stop(
        &params.session_id,
        state.voice_sessions.clone(),
    )
    .await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

#[derive(Deserialize)]
struct VoicePushParams {
    session_id: String,
    /// Base64-encoded 16-bit signed PCM frame. JSON-RPC is text, so binary
    /// audio frames have to be encoded; clients in WS-binary contexts that
    /// want to skip the round trip can call the FFI directly.
    pcm_b64: String,
}

async fn handle_voice_transcribe_stream_push(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    use base64::Engine;
    let params: VoicePushParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let pcm = base64::engine::general_purpose::STANDARD
        .decode(&params.pcm_b64)
        .map_err(|e| format!("invalid pcm_b64: {}", e))?;
    let json = car_ffi_common::voice::transcribe_stream_push(
        &params.session_id,
        &pcm,
        state.voice_sessions.clone(),
    )
    .await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

fn handle_voice_sessions_list(state: &Arc<ServerState>) -> Value {
    let json = car_ffi_common::voice::list_voice_sessions(state.voice_sessions.clone());
    serde_json::from_str(&json).unwrap_or(Value::Null)
}

#[derive(Deserialize)]
struct VoiceTtsStreamStartParams {
    /// Caller-chosen opaque id for this stream. Used both as the
    /// session_id wrapped onto each `voice.event` notification AND as
    /// the key for `voice.tts_stream.cancel`.
    stream_id: String,
    /// Text to synthesize. Splitting into multiple synth calls (for
    /// long-form narration) is the caller's responsibility.
    text: String,
    /// Optional [`car_ffi_common::voice::TtsStreamOptions`] as a raw
    /// JSON value (provider, voice_id, binary_frames).
    #[serde(default)]
    options: Option<Value>,
}

async fn handle_voice_tts_stream_start(
    req: &JsonRpcMessage,
    session: &Arc<crate::session::ClientSession>,
) -> Result<Value, String> {
    let params: VoiceTtsStreamStartParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let opts_str = params
        .options
        .as_ref()
        .map(|v| v.to_string())
        .filter(|s| !s.is_empty());
    let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
        channel: session.channel.clone(),
    });
    let json = car_ffi_common::voice::tts_stream_start(
        &params.stream_id,
        &params.text,
        opts_str.as_deref(),
        sink,
    )
    .await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

#[derive(Deserialize)]
struct VoiceTtsStreamCancelParams {
    stream_id: String,
}

async fn handle_voice_tts_stream_cancel(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: VoiceTtsStreamCancelParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let json = car_ffi_common::voice::tts_stream_cancel(&params.stream_id).await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

fn handle_voice_tts_stream_list() -> Value {
    let json = car_ffi_common::voice::list_tts_streams();
    serde_json::from_str(&json).unwrap_or(Value::Null)
}

async fn handle_voice_dispatch_turn(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
    session: &Arc<crate::session::ClientSession>,
) -> Result<Value, String> {
    let req_value = req.params.clone();
    let request: crate::voice_turn::DispatchVoiceTurnRequest =
        serde_json::from_value(req_value).map_err(|e| e.to_string())?;
    let engine = get_inference_engine(state).clone();
    let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
        channel: session.channel.clone(),
    });
    let resp = crate::voice_turn::dispatch(engine, request, sink).await?;
    serde_json::to_value(resp).map_err(|e| e.to_string())
}

async fn handle_voice_cancel_turn() -> Result<Value, String> {
    crate::voice_turn::cancel().await;
    Ok(serde_json::json!({"cancelled": true}))
}

async fn handle_voice_prewarm_turn(state: &Arc<ServerState>) -> Result<Value, String> {
    let engine = get_inference_engine(state).clone();
    crate::voice_turn::prewarm(engine).await;
    Ok(serde_json::json!({"prewarmed": true}))
}

// ---------------------------------------------------------------------------
// Inference runner over WebSocket — closes Parslee-ai/car-releases#24
//
// Bidirectional protocol shape:
//   1. Client → server: `inference.register_runner` (no params). The
//      session that calls this becomes the host for delegated models.
//   2. Server → client: `inference.runner.invoke` notification with
//      {call_id, request} when CAR needs to dispatch a delegated turn.
//   3. Client → server: `inference.runner.event` with {call_id, event}
//      for each chunk; `inference.runner.complete` with {call_id, result}
//      on success; `inference.runner.fail` with {call_id, error} on
//      failure.
//
// The server-side data is process-wide because only one inference
// runner can be registered at a time (matches the FFI bindings'
// constraint). The per-call mailboxes live in dedicated DashMaps.
// ---------------------------------------------------------------------------

fn ws_runner_session() -> &'static std::sync::RwLock<Option<Arc<crate::session::WsChannel>>> {
    static SLOT: std::sync::OnceLock<std::sync::RwLock<Option<Arc<crate::session::WsChannel>>>> =
        std::sync::OnceLock::new();
    SLOT.get_or_init(|| std::sync::RwLock::new(None))
}

fn ws_runner_calls() -> &'static dashmap::DashMap<String, car_inference::EventEmitter> {
    static MAP: std::sync::OnceLock<dashmap::DashMap<String, car_inference::EventEmitter>> =
        std::sync::OnceLock::new();
    MAP.get_or_init(dashmap::DashMap::new)
}

fn ws_runner_completions() -> &'static dashmap::DashMap<
    String,
    tokio::sync::oneshot::Sender<std::result::Result<car_inference::RunnerResult, String>>,
> {
    static MAP: std::sync::OnceLock<
        dashmap::DashMap<
            String,
            tokio::sync::oneshot::Sender<std::result::Result<car_inference::RunnerResult, String>>,
        >,
    > = std::sync::OnceLock::new();
    MAP.get_or_init(dashmap::DashMap::new)
}

struct WsInferenceRunner;

#[async_trait::async_trait]
impl car_inference::InferenceRunner for WsInferenceRunner {
    async fn run(
        &self,
        request: car_inference::tasks::generate::GenerateRequest,
        emitter: car_inference::EventEmitter,
    ) -> std::result::Result<car_inference::RunnerResult, car_inference::RunnerError> {
        let channel = ws_runner_session()
            .read()
            .map_err(|e| {
                car_inference::RunnerError::Failed(format!("ws runner slot poisoned: {e}"))
            })?
            .clone()
            .ok_or_else(|| {
                car_inference::RunnerError::Declined(
                    "no WebSocket inference runner registered — call inference.register_runner first"
                        .into(),
                )
            })?;

        let call_id = uuid::Uuid::new_v4().to_string();
        let request_json = serde_json::to_value(&request)
            .map_err(|e| car_inference::RunnerError::Failed(e.to_string()))?;
        let (tx, rx) = tokio::sync::oneshot::channel();
        ws_runner_calls().insert(call_id.clone(), emitter);
        ws_runner_completions().insert(call_id.clone(), tx);

        // Fire the invoke notification.
        use futures::SinkExt;
        let notification = serde_json::json!({
            "jsonrpc": "2.0",
            "method": "inference.runner.invoke",
            "params": {
                "call_id": call_id,
                "request": request_json,
            },
        });
        let text = serde_json::to_string(&notification)
            .map_err(|e| car_inference::RunnerError::Failed(e.to_string()))?;
        let _ = channel
            .write
            .lock()
            .await
            .send(tokio_tungstenite::tungstenite::Message::Text(text.into()))
            .await;

        let result = rx.await.map_err(|_| {
            car_inference::RunnerError::Failed("runner completion channel dropped".into())
        })?;
        ws_runner_calls().remove(&call_id);
        result.map_err(car_inference::RunnerError::Failed)
    }
}

async fn handle_inference_register_runner(
    session: &Arc<crate::session::ClientSession>,
) -> Result<Value, String> {
    let mut guard = ws_runner_session()
        .write()
        .map_err(|e| format!("ws runner slot poisoned: {e}"))?;
    *guard = Some(session.channel.clone());
    drop(guard);
    car_inference::set_inference_runner(Some(Arc::new(WsInferenceRunner)));
    Ok(serde_json::json!({"registered": true}))
}

#[derive(serde::Deserialize)]
struct InferenceRunnerEventParams {
    call_id: String,
    event: Value,
}

async fn handle_inference_runner_event(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: InferenceRunnerEventParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let stream_event = match parse_runner_event_value(&params.event) {
        Some(e) => e,
        None => return Err("unrecognised runner event shape".into()),
    };
    if let Some(entry) = ws_runner_calls().get(&params.call_id) {
        let emitter = entry.value().clone();
        tokio::spawn(async move { emitter.emit(stream_event).await });
    }
    Ok(serde_json::json!({"emitted": true}))
}

#[derive(serde::Deserialize)]
struct InferenceRunnerCompleteParams {
    call_id: String,
    result: Value,
}

async fn handle_inference_runner_complete(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: InferenceRunnerCompleteParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let result: std::result::Result<car_inference::RunnerResult, String> =
        serde_json::from_value(params.result)
            .map_err(|e| format!("invalid RunnerResult JSON: {e}"));
    if let Some((_, tx)) = ws_runner_completions().remove(&params.call_id) {
        let _ = tx.send(result);
    }
    Ok(serde_json::json!({"completed": true}))
}

#[derive(serde::Deserialize)]
struct InferenceRunnerFailParams {
    call_id: String,
    error: String,
}

async fn handle_inference_runner_fail(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: InferenceRunnerFailParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    if let Some((_, tx)) = ws_runner_completions().remove(&params.call_id) {
        let _ = tx.send(Err(params.error));
    }
    Ok(serde_json::json!({"failed": true}))
}

fn parse_runner_event_value(v: &Value) -> Option<car_inference::StreamEvent> {
    let ty = v.get("type").and_then(|t| t.as_str())?;
    match ty {
        "text" => Some(car_inference::StreamEvent::TextDelta(
            v.get("data")?.as_str()?.to_string(),
        )),
        "tool_start" => Some(car_inference::StreamEvent::ToolCallStart {
            name: v.get("name")?.as_str()?.to_string(),
            index: v.get("index")?.as_u64()? as usize,
            id: v.get("id").and_then(|i| i.as_str()).map(str::to_string),
        }),
        "tool_delta" => Some(car_inference::StreamEvent::ToolCallDelta {
            index: v.get("index")?.as_u64()? as usize,
            arguments_delta: v.get("data")?.as_str()?.to_string(),
        }),
        "usage" => Some(car_inference::StreamEvent::Usage {
            input_tokens: v.get("input_tokens")?.as_u64()?,
            output_tokens: v.get("output_tokens")?.as_u64()?,
            cache_read_input_tokens: v
                .get("cache_read_input_tokens")
                .and_then(|x| x.as_u64())
                .unwrap_or(0),
            cache_creation_input_tokens: v
                .get("cache_creation_input_tokens")
                .and_then(|x| x.as_u64())
                .unwrap_or(0),
        }),
        "done" => Some(car_inference::StreamEvent::Done {
            text: v.get("text")?.as_str()?.to_string(),
            tool_calls: v
                .get("tool_calls")
                .and_then(|tc| serde_json::from_value(tc.clone()).ok())
                .unwrap_or_default(),
        }),
        _ => None,
    }
}

#[derive(Deserialize)]
struct EnrollSpeakerParams {
    label: String,
    audio: Value,
}

async fn handle_enroll_speaker(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: EnrollSpeakerParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let audio_json = serde_json::to_string(&params.audio).map_err(|e| e.to_string())?;
    let json = car_ffi_common::voice::enroll_speaker(&params.label, &audio_json).await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

#[derive(Deserialize)]
struct RemoveEnrollmentParams {
    label: String,
}

fn handle_remove_enrollment(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: RemoveEnrollmentParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let json = car_ffi_common::voice::remove_enrollment(&params.label)?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

#[derive(Deserialize)]
struct WorkflowRunParams {
    workflow: Value,
    /// Optional state map seeded into workflow state before the run starts —
    /// the inter-workflow chaining hook (hand a prior run's `final_state` to
    /// the next workflow). Omitted = prior behavior.
    #[serde(default)]
    initial_state: Option<std::collections::HashMap<String, Value>>,
}

async fn handle_workflow_run(
    req: &JsonRpcMessage,
    session: &Arc<crate::session::ClientSession>,
) -> Result<Value, String> {
    let params: WorkflowRunParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let workflow_json = serde_json::to_string(&params.workflow).map_err(|e| e.to_string())?;
    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
        channel: session.channel.clone(),
        host: session.host.clone(),
        client_id: session.client_id.clone(),
    });
    let json =
        car_ffi_common::workflow::run_workflow(&workflow_json, params.initial_state, runner)
            .await?;
    let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
    // If the run parked at an approval gate, persist the checkpoint durably so
    // it survives a daemon restart and can be resumed by run_id.
    persist_if_paused(&result)?;
    Ok(result)
}

#[derive(Deserialize)]
struct WorkflowChainParams {
    /// Workflow definitions run sequentially: each next workflow's initial
    /// state is the previous result's `final_state` merged over
    /// `initial_state`.
    workflows: Vec<Value>,
    /// Optional base state seeded into every workflow in the chain (the first
    /// workflow gets exactly this; later ones get it merged under the
    /// predecessor's `final_state`).
    #[serde(default)]
    initial_state: Option<std::collections::HashMap<String, Value>>,
}

/// `workflow.chain` — run workflows sequentially, threading each result's
/// `final_state` into the next workflow's initial state. Every workflow is
/// statically pre-validated before any executes (structural garbage rejects
/// the chain up front, as an error). Stops at the first non-`completed`
/// result, returning the results so far plus the stopping status; a paused
/// intermediate persists its checkpoint durably exactly like `workflow.run`
/// (resume it via `workflow.resume` by `run_id`, then re-chain the remainder
/// if desired) and is named by `paused_at_index`. A mid-chain *runtime*
/// engine error (e.g. cycle limit) does not discard the chain: the response
/// still carries `results` so far (delivery evidence included) plus
/// top-level `error` and `failed_at_index`.
async fn handle_workflow_chain(
    req: &JsonRpcMessage,
    session: &Arc<crate::session::ClientSession>,
) -> Result<Value, String> {
    let params: WorkflowChainParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let workflows_json =
        serde_json::to_string(&params.workflows).map_err(|e| e.to_string())?;
    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
        channel: session.channel.clone(),
        host: session.host.clone(),
        client_id: session.client_id.clone(),
    });
    let json =
        car_ffi_common::workflow::chain_workflows(&workflows_json, params.initial_state, runner)
            .await?;
    let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
    // Only the stopping workflow can be paused (the chain halts there);
    // persist its checkpoint durably so workflow.resume can pick it up.
    if let Some(last) = result
        .get("results")
        .and_then(|r| r.as_array())
        .and_then(|r| r.last())
    {
        persist_if_paused(last)?;
    }
    Ok(result)
}

/// Re-arm workflow runs orphaned by a crash between an approval `claim` and its
/// `complete`. Call **once at daemon startup**, before serving connections, so a
/// restart mid-approval doesn't bury paused runs. Best-effort: logs and returns
/// on any error rather than failing boot.
pub fn recover_workflow_checkpoints() {
    let dir = match workflow_runs_dir() {
        Ok(d) => d,
        Err(e) => {
            tracing::debug!(error = %e, "no workflow checkpoint dir; skipping recovery");
            return;
        }
    };
    match car_workflow::CheckpointStore::open(&dir).and_then(|s| s.recover_orphaned()) {
        Ok(0) => {}
        Ok(n) => tracing::info!(rearmed = n, "recovered orphaned workflow checkpoints"),
        Err(e) => tracing::warn!(error = %e, "workflow checkpoint recovery failed"),
    }
}

/// Directory holding durable workflow checkpoints (`~/.car/workflow-runs`).
fn workflow_runs_dir() -> Result<std::path::PathBuf, String> {
    let home = std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .map_err(|_| "cannot resolve home directory for workflow checkpoints".to_string())?;
    Ok(std::path::PathBuf::from(home)
        .join(".car")
        .join("workflow-runs"))
}

/// If `result` is a paused WorkflowResult, save its checkpoint to the store.
fn persist_if_paused(result: &Value) -> Result<(), String> {
    if result.get("status").and_then(|s| s.as_str()) != Some("paused") {
        return Ok(());
    }
    let Some(paused_value) = result.get("paused") else {
        return Err("paused result missing checkpoint".to_string());
    };
    let paused: car_workflow::PausedWorkflow =
        serde_json::from_value(paused_value.clone()).map_err(|e| e.to_string())?;
    let store =
        car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
    store.save(&paused).map_err(|e| e.to_string())
}

#[derive(Deserialize)]
struct WorkflowResumeParams {
    run_id: String,
    #[serde(default)]
    input: Value,
}

/// `workflow.list_paused` — list resumable workflow runs with their pause
/// metadata (EPIC H / H1). The discovery half of durable resume: after a daemon
/// restart a client no longer holds the paused `run_id`s, so it enumerates them
/// here (each `{run_id, paused_stage_id, prompt, created_at}`) and resumes by
/// `run_id` via `workflow.resume`. In-flight and corrupt checkpoints are
/// omitted — only genuinely resumable runs are returned.
async fn handle_workflow_list_paused() -> Result<Value, String> {
    let store =
        car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
    let summaries = store.list_summaries().map_err(|e| e.to_string())?;
    serde_json::to_value(&summaries).map_err(|e| format!("serialize list_paused: {e}"))
}

async fn handle_workflow_resume(
    req: &JsonRpcMessage,
    session: &Arc<crate::session::ClientSession>,
) -> Result<Value, String> {
    let params: WorkflowResumeParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;

    let store =
        car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
    // Atomic claim: a duplicate/racing resume of the same run gets nothing,
    // so a side-effecting downstream stage never runs twice.
    let paused = store
        .claim(&params.run_id)
        .map_err(|e| e.to_string())?
        .ok_or_else(|| {
            format!(
                "no paused workflow run '{}' (already resumed or unknown)",
                params.run_id
            )
        })?;
    let paused_json = serde_json::to_string(&paused).map_err(|e| e.to_string())?;
    let input_json = if params.input.is_null() {
        "{}".to_string()
    } else {
        serde_json::to_string(&params.input).map_err(|e| e.to_string())?
    };

    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
        channel: session.channel.clone(),
        host: session.host.clone(),
        client_id: session.client_id.clone(),
    });

    let json = car_ffi_common::workflow::resume_workflow(&paused_json, &input_json, runner).await;
    let result = match json {
        Ok(j) => serde_json::from_str::<Value>(&j).map_err(|e| e.to_string())?,
        Err(e) => {
            // Resume failed (e.g. invalid input) — release the in-flight marker
            // so the run can be resumed again with corrected input.
            let _ = store.save(&paused);
            let _ = store.complete(&params.run_id);
            return Err(e);
        }
    };
    // Re-paused at another gate → persist the fresh checkpoint; either way drop
    // the in-flight marker from the claim.
    persist_if_paused(&result)?;
    store.complete(&params.run_id).map_err(|e| e.to_string())?;
    Ok(result)
}

#[derive(Deserialize)]
struct BuilderBuildParams {
    goal: String,
    #[serde(default)]
    existing: Value,
    #[serde(default = "default_builder_attempts")]
    max_attempts: u32,
}

fn default_builder_attempts() -> u32 {
    3
}

/// `builder.build` — natural language → validated workflow. Runs on the daemon so
/// the catalog is authoritative: tools come from this session's registered tool
/// schemas and models from the inference registry, making the builder's
/// tool-existence cross-check meaningful.
async fn handle_builder_build(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
    session: &Arc<crate::session::ClientSession>,
) -> Result<Value, String> {
    let params: BuilderBuildParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    if params.goal.trim().is_empty() {
        return Err("missing 'goal'".to_string());
    }

    let engine = get_inference_engine(state).clone();

    let tools: Vec<car_builder::ToolInfo> = session
        .runtime
        .registry
        .schemas()
        .await
        .into_iter()
        .map(|s| car_builder::ToolInfo {
            name: s.name,
            description: s.description,
        })
        .collect();
    let models: Vec<String> = engine
        .list_models_unified()
        .into_iter()
        .map(|m| m.id)
        .collect();
    let catalog = car_builder::ToolCatalog {
        tools,
        models,
        agents: Vec::new(),
    };

    let existing = if params.existing.is_null() {
        None
    } else {
        serde_json::from_value::<car_workflow::Workflow>(params.existing.clone()).ok()
    };

    let build_req = car_builder::BuildRequest {
        goal: params.goal,
        catalog,
        existing,
        feedback: None,
        max_attempts: params.max_attempts,
    };

    let result = car_builder::build_workflow(
        |prompt: String| {
            let engine = engine.clone();
            async move {
                let greq = car_inference::GenerateRequest {
                    prompt,
                    model: None,
                    params: car_inference::GenerateParams {
                        temperature: 0.2,
                        max_tokens: 4096,
                        ..Default::default()
                    },
                    context: None,
                    context_stable_prefix: None,
                    tools: None,
                    images: None,
                    messages: None,
                    cache_control: false,
                    response_format: None,
                    intent: None,
                };
                engine
                    .generate_tracked(greq)
                    .await
                    .map(|r| r.text)
                    .map_err(|e| e.to_string())
            }
        },
        &build_req,
    )
    .await;

    Ok(serde_json::json!({
        "valid": result.valid,
        "workflow": result.workflow,
        "issues": result.issues,
        "warnings": result.warnings,
        "attempts": result.attempts,
    }))
}

#[derive(Deserialize)]
struct WorkflowVerifyParams {
    workflow: Value,
}

fn handle_workflow_verify(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: WorkflowVerifyParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let workflow_json = serde_json::to_string(&params.workflow).map_err(|e| e.to_string())?;
    let json = car_ffi_common::workflow::verify_workflow(&workflow_json)?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

#[derive(Deserialize)]
struct WorkflowBuildAutomationParams {
    spec: Value,
}

/// Lower an external-item `AutomationSpec` (poll → dedup → per-item agent →
/// deliver) into a runnable workflow definition. Stateless; the caller hands the
/// returned workflow back to `workflow.run` (typically on a schedule).
fn handle_workflow_build_automation(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: WorkflowBuildAutomationParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let spec_json = serde_json::to_string(&params.spec).map_err(|e| e.to_string())?;
    let json = car_ffi_common::workflow::build_automation_workflow(&spec_json)?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

// ---------------------------------------------------------------------------
// Meeting JSON-RPC methods
// ---------------------------------------------------------------------------

async fn handle_meeting_start(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
    session: &Arc<crate::session::ClientSession>,
) -> Result<Value, String> {
    // We need the meeting id BEFORE handing the upstream sink to
    // start_meeting so the WsMemgineIngestSink stamps transcripts with
    // the correct `meeting/<id>/<source>` speaker. Parse the request
    // here, mint an id if none was provided, and pass the same id
    // through to start_meeting via the request JSON.
    let mut req_value = req.params.clone();
    let meeting_id = req_value
        .get("id")
        .and_then(|v| v.as_str())
        .map(str::to_string)
        .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string());
    if let Some(map) = req_value.as_object_mut() {
        map.insert("id".into(), Value::String(meeting_id.clone()));
    }
    let request_json = serde_json::to_string(&req_value).map_err(|e| e.to_string())?;

    let ws_upstream: Arc<dyn car_voice::VoiceEventSink> =
        Arc::new(crate::session::WsVoiceEventSink {
            channel: session.channel.clone(),
        });

    // Wrap the WS upstream with a memgine-ingest fanout that uses the
    // tokio::sync::Mutex-wrapped session memgine. We pass `None` for
    // the FFI-common `start_meeting` memgine arg to avoid the
    // sync-mutex contract there — ingest happens here instead.
    let upstream: Arc<dyn car_voice::VoiceEventSink> =
        Arc::new(crate::session::WsMemgineIngestSink {
            meeting_id,
            engine: session.memgine.clone(),
            upstream: ws_upstream,
        });

    let cwd = std::env::current_dir().ok();
    let json = crate::meeting::start_meeting(
        &request_json,
        state.meetings.clone(),
        state.voice_sessions.clone(),
        upstream,
        None,
        cwd,
    )
    .await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

#[derive(Deserialize)]
struct MeetingStopParams {
    meeting_id: String,
    #[serde(default = "default_summarize")]
    summarize: bool,
}

fn default_summarize() -> bool {
    true
}

async fn handle_meeting_stop(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
    _session: &Arc<crate::session::ClientSession>,
) -> Result<Value, String> {
    let params: MeetingStopParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let inference = if params.summarize {
        Some(state.inference.get().cloned()).flatten()
    } else {
        None
    };
    let json = crate::meeting::stop_meeting(
        &params.meeting_id,
        params.summarize,
        state.meetings.clone(),
        state.voice_sessions.clone(),
        inference,
    )
    .await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

#[derive(Deserialize, Default)]
struct MeetingListParams {
    #[serde(default)]
    root: Option<std::path::PathBuf>,
}

fn handle_meeting_list(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: MeetingListParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
    let cwd = std::env::current_dir().ok();
    let json = crate::meeting::list_meetings(params.root, cwd)?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

#[derive(Deserialize)]
struct MeetingGetParams {
    meeting_id: String,
    #[serde(default)]
    root: Option<std::path::PathBuf>,
}

fn handle_meeting_get(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: MeetingGetParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let cwd = std::env::current_dir().ok();
    let json = crate::meeting::get_meeting(&params.meeting_id, params.root, cwd)?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

// ---------------------------------------------------------------------------
// Agent registry — file-based cross-process discovery (#111)
// ---------------------------------------------------------------------------

#[derive(Deserialize, Default)]
struct RegistryRegisterParams {
    /// Caller serializes their AgentEntry as a JSON value; we
    /// re-serialize it so the ffi-common helper can validate the
    /// shape with the same parser used by the bindings.
    entry: Value,
    #[serde(default)]
    registry_path: Option<std::path::PathBuf>,
}

fn handle_registry_register(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: RegistryRegisterParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let entry_json = serde_json::to_string(&params.entry).map_err(|e| e.to_string())?;
    car_ffi_common::registry::register_agent(&entry_json, params.registry_path)?;
    Ok(Value::Null)
}

#[derive(Deserialize, Default)]
struct RegistryNameParams {
    name: String,
    #[serde(default)]
    registry_path: Option<std::path::PathBuf>,
}

fn handle_registry_heartbeat(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: RegistryNameParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let json = car_ffi_common::registry::agent_heartbeat(&params.name, params.registry_path)?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

fn handle_registry_unregister(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: RegistryNameParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    car_ffi_common::registry::unregister_agent(&params.name, params.registry_path)?;
    Ok(Value::Null)
}

#[derive(Deserialize, Default)]
struct RegistryListParams {
    #[serde(default)]
    registry_path: Option<std::path::PathBuf>,
}

fn handle_registry_list(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: RegistryListParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
    let json = car_ffi_common::registry::list_agents(params.registry_path)?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

#[derive(Deserialize, Default)]
struct RegistryReapParams {
    /// Heartbeats older than this many seconds are reaped. Default
    /// 60 — two missed 20s heartbeats trigger removal.
    #[serde(default = "default_reap_age")]
    max_age_secs: u64,
    #[serde(default)]
    registry_path: Option<std::path::PathBuf>,
}

fn default_reap_age() -> u64 {
    60
}

fn handle_registry_reap(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: RegistryReapParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
    let json =
        car_ffi_common::registry::reap_stale_agents(params.max_age_secs, params.registry_path)?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

// ---------------------------------------------------------------------------
// car-a2a server lifecycle (mirrors NAPI startA2AServer / stopA2AServer /
// a2AServerStatus and PyO3 start_a2a_server / stop_a2a_server /
// a2a_server_status — closes the binding gap noted in #126).
// ---------------------------------------------------------------------------

async fn handle_a2a_start(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
    session: &crate::session::ClientSession,
) -> Result<Value, String> {
    let params_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
    // Always hand the session's runtime through. start_a2a uses it
    // only when `share_session_runtime: true` is set in params;
    // otherwise it falls back to the legacy fresh-Runtime + agent_basics
    // path. Passing it unconditionally keeps the FFI layer ignorant of
    // the flag's plumbing.
    //
    // Also hand through a chat responder bound to THIS session so a
    // conversational (text-only) `message/send` to the A2A listener routes back
    // to this host agent's `agent.chat` handler (car-releases#65). start_a2a
    // wires it only when `share_session_runtime: true` — without the shared
    // runtime there's no host loop to route to.
    let responder: Arc<dyn car_a2a::ChatResponder> = Arc::new(WsChatResponder {
        state: Arc::downgrade(state),
        host_client_id: session.client_id.clone(),
    });
    let json =
        crate::a2a::start_a2a(&params_json, Some(session.runtime.clone()), Some(responder)).await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

fn handle_a2a_stop() -> Result<Value, String> {
    let json = crate::a2a::stop_a2a()?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

fn handle_a2a_status() -> Result<Value, String> {
    let json = crate::a2a::a2a_status()?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

// a2a.peers.* — registry of remote A2A peers CAR can discover/call. Reachable
// from the language bindings via the generic `a2a_dispatch` proxy.

#[derive(serde::Deserialize)]
struct A2aPeerAddParams {
    url: String,
    #[serde(default)]
    label: Option<String>,
    /// Opt-in to register a non-loopback peer (see the gate below).
    #[serde(default)]
    allow_untrusted: bool,
}

fn handle_a2a_peers_add(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: A2aPeerAddParams =
        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
    // `discovery.resolve` will issue an outbound GET to this URL's agent card,
    // so registering a non-loopback peer is a deliberate trust decision — gate
    // it the same way `a2a.send` gates its endpoint (SSRF guard: otherwise any
    // registered URL, incl. internal/metadata endpoints, becomes a daemon GET).
    if !params.allow_untrusted && !is_loopback_http_endpoint(&params.url) {
        return Err(
            "a2a.peers.add endpoint must be loopback unless allowUntrusted is true".to_string(),
        );
    }
    let reg = car_a2a::peers::PeerRegistry::user_default()?;
    let entry = reg.add(&params.url, params.label)?;
    serde_json::to_value(entry).map_err(|e| e.to_string())
}

fn handle_a2a_peers_list() -> Result<Value, String> {
    let reg = car_a2a::peers::PeerRegistry::user_default()?;
    Ok(serde_json::json!({ "peers": reg.list() }))
}

#[derive(serde::Deserialize)]
struct A2aPeerRemoveParams {
    slug: String,
}

fn handle_a2a_peers_remove(req: &JsonRpcMessage) -> Result<Value, String> {
    let params: A2aPeerRemoveParams =
        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
    let reg = car_a2a::peers::PeerRegistry::user_default()?;
    Ok(serde_json::json!({ "removed": reg.remove(&params.slug)? }))
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct A2aSendParams {
    endpoint: String,
    message: car_a2a::Message,
    #[serde(default)]
    blocking: bool,
    #[serde(default = "default_true")]
    ingest_a2ui: bool,
    #[serde(default)]
    route_auth: Option<A2aRouteAuth>,
    #[serde(default)]
    allow_untrusted_endpoint: bool,
}

fn default_true() -> bool {
    true
}

/// In-core A2A dispatcher entry point. Forwards the JSON-RPC method
/// + params to the lazy-initialized [`car_a2a::A2aDispatcher`] held
/// on `ServerState`. Closes Parslee-ai/car-releases#28.
///
/// Streaming methods (`message/stream`, `tasks/resubscribe` and their
/// PascalCase aliases) return `MethodNotFound` from the dispatcher's
/// transport-neutral surface — the standalone `start_a2a_listener`
/// HTTP path serves SSE for those, but the in-core WS surface is
/// JSON-RPC only. Same trade as the dispatcher itself.
async fn handle_a2a_dispatch(
    method: &str,
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let dispatcher = state.a2a_dispatcher().await;
    dispatcher
        .dispatch(method, req.params.clone())
        .await
        .map_err(|e| e.to_string())
}

async fn handle_a2a_send(req: &JsonRpcMessage, state: &Arc<ServerState>) -> Result<Value, String> {
    let params: A2aSendParams =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let endpoint = trusted_route_endpoint(
        Some(params.endpoint.clone()),
        params.allow_untrusted_endpoint,
    )
    .ok_or_else(|| {
        "`a2a.send` endpoint must be loopback unless allowUntrustedEndpoint is true".to_string()
    })?;
    let client = match params.route_auth.clone() {
        Some(auth) => {
            car_a2a::A2aClient::new(endpoint.clone()).with_auth(client_auth_from_route_auth(auth))
        }
        None => car_a2a::A2aClient::new(endpoint.clone()),
    };
    let result = client
        .send_message(params.message, params.blocking)
        .await
        .map_err(|e| e.to_string())?;
    let result_value = serde_json::to_value(&result).map_err(|e| e.to_string())?;
    let mut applied = Vec::new();
    if params.ingest_a2ui {
        state
            .a2ui
            .validate_payload(&result_value)
            .map_err(|e| e.to_string())?;
        let routed_endpoint = Some(endpoint.clone());
        for envelope in car_a2ui::envelopes_from_value(&result_value).map_err(|e| e.to_string())? {
            let owner = car_a2ui::owner_from_value(&result_value).map(|owner| {
                if owner.endpoint.is_none() {
                    owner.with_endpoint(routed_endpoint.clone())
                } else {
                    owner
                }
            });
            applied.push(
                apply_a2ui_envelope(state, envelope, owner, params.route_auth.clone()).await?,
            );
        }
    }
    Ok(serde_json::json!({
        "result": result,
        "a2ui": {
            "applied": applied,
        }
    }))
}

// ---------------------------------------------------------------------------
// macOS automation — AppleScript + Shortcuts (car-automation), Vision OCR
// (car-vision). Mirrors NAPI runApplescript / listShortcuts / runShortcut /
// visionOcr and PyO3 run_applescript / list_shortcuts / run_shortcut /
// vision_ocr.
// ---------------------------------------------------------------------------

async fn handle_run_applescript(req: &JsonRpcMessage) -> Result<Value, String> {
    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
    let json = car_ffi_common::automation::run_applescript(&args_json).await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

async fn handle_list_shortcuts(req: &JsonRpcMessage) -> Result<Value, String> {
    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
    let json = car_ffi_common::automation::list_shortcuts(&args_json).await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

async fn handle_run_shortcut(req: &JsonRpcMessage) -> Result<Value, String> {
    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
    let json = car_ffi_common::automation::run_shortcut(&args_json).await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

async fn handle_local_notification(req: &JsonRpcMessage) -> Result<Value, String> {
    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
    let json = car_ffi_common::notifications::local(&args_json).await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

async fn handle_vision_ocr(req: &JsonRpcMessage) -> Result<Value, String> {
    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
    let json = car_ffi_common::vision::ocr(&args_json).await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

// ---------------------------------------------------------------------------
// Lifecycle-managed agents (car_registry::supervisor) — Parslee-ai/car-releases#27
// ---------------------------------------------------------------------------

async fn handle_agents_list(state: &Arc<ServerState>) -> Result<Value, String> {
    // Observe-only mode (Parslee-ai/car-releases#44): a second
    // car-server on the host can't take the supervisor lock, so it
    // can't drive `Supervisor::list` — but it can still answer
    // `agents.list` by reading the on-disk manifest directly. The
    // `attached` decoration is local to whichever daemon the caller
    // is talking to, so observer-mode entries return `attached:
    // false` (this daemon hasn't received `session.auth` from those
    // children; the primary one has).
    let agents = match state.observer_manifest_path() {
        Some(p) => car_registry::supervisor::Supervisor::list_from_manifest(p)
            .map_err(|e| e.to_string())?,
        None => {
            let supervisor = state.supervisor()?;
            supervisor.list().await
        }
    };
    // Decorate each entry with `attached` + `session_id` so operators
    // see whether the supervised process has actually called
    // `session.auth { agent_id }` and bound a WS connection (#169) —
    // the lifecycle status (`Running`, etc.) only reports the
    // process-level view, which can't tell "alive but never
    // attached" from "alive and attached".
    let attached = state.attached_agents.lock().await.clone();
    let mut decorated: Vec<Value> = Vec::with_capacity(agents.len());
    for a in agents {
        let mut v = serde_json::to_value(&a).map_err(|e| e.to_string())?;
        let session_id = attached.get(&a.spec.id).cloned();
        if let Some(map) = v.as_object_mut() {
            map.insert("attached".to_string(), Value::Bool(session_id.is_some()));
            if let Some(sid) = session_id {
                map.insert("session_id".to_string(), Value::String(sid));
            }
        }
        decorated.push(v);
    }
    // Merge in declarative (in-daemon) agents the coder→agent loop built. They
    // carry `kind:"declarative"` + `enabled` instead of process status, and
    // never travel through the supervisor's command-validation path.
    decorated.extend(crate::coder::rpc::declarative_agent_rows(state).await);
    Ok(Value::Array(decorated))
}

async fn handle_agents_upsert(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let mut params = req.params.clone();
    // Optional `interpreter` sugar (#171). When present, the
    // supervisor resolves the bare program name (`"node"`,
    // `"python"`, …) against `$PATH` and writes the absolute path
    // into `command` *before* validation. This keeps the strict
    // no-PATH-lookup rule at upsert time while letting callers
    // stop hand-coding `/opt/homebrew/bin/node` into every
    // agents.json entry. Resolution happens once; subsequent PATH
    // changes do not silently rewire the binding.
    if let Some(name) = params
        .get("interpreter")
        .and_then(|v| v.as_str())
        .map(str::to_string)
    {
        let resolved =
            car_registry::supervisor::resolve_interpreter(&name).map_err(|e| e.to_string())?;
        params["command"] = Value::String(resolved.to_string_lossy().into_owned());
    }
    let spec: car_registry::supervisor::AgentSpec =
        serde_json::from_value(params).map_err(|e| e.to_string())?;
    let supervisor = state.supervisor()?;
    let agent = supervisor.upsert(spec).await.map_err(|e| e.to_string())?;
    serde_json::to_value(agent).map_err(|e| e.to_string())
}

/// `agents.install` — install a contributed-agent manifest
/// (Parslee-ai/car#182 phase 3). Caller passes the parsed
/// `AgentManifest` JSON; the daemon runs install-time validation
/// (`car_min_version`, capability negotiation against the daemon's
/// own advertisement) and adopts the manifest. Returns
/// `{ report, agent? }` where `agent` is the spawnable
/// `ManagedAgent` for `external_process` transports and absent for
/// `pure_data` / health_url-only manifests.
///
/// The host capability advertisement comes from
/// `HostCapabilities::daemon_default(car_version)` — operators that
/// want a tighter advertisement go through a future config phase;
/// this MVP uses the runtime's natural surface.
async fn handle_agents_install(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let manifest: car_registry::manifest::AgentManifest =
        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
    let host = car_registry::install::HostCapabilities::daemon_default(env!("CARGO_PKG_VERSION"));
    let supervisor = state.supervisor()?;
    let (report, managed) = supervisor
        .install_manifest(manifest, &host)
        .await
        .map_err(|e| e.to_string())?;
    Ok(serde_json::json!({
        "report": {
            "missingOptional": report
                .missing_optional
                .iter()
                .map(|(ns, feat)| serde_json::json!({ "namespace": ns, "feature": feat }))
                .collect::<Vec<_>>(),
        },
        "agent": managed,
    }))
}

async fn handle_agents_health(state: &Arc<ServerState>) -> Result<Value, String> {
    // Observe-only mode (Parslee-ai/car-releases#44) — see
    // `handle_agents_list` for the rationale. The health view is a
    // pure function of each entry's `command` plus the on-disk
    // sandbox rules, so reading from the manifest is equivalent to
    // calling the live supervisor's `health()`.
    let entries = match state.observer_manifest_path() {
        Some(p) => car_registry::supervisor::Supervisor::health_from_manifest(p)
            .map_err(|e| e.to_string())?,
        None => {
            let supervisor = state.supervisor()?;
            supervisor.health().await
        }
    };
    serde_json::to_value(entries).map_err(|e| e.to_string())
}

fn extract_agent_id(req: &JsonRpcMessage) -> Result<String, String> {
    req.params
        .get("id")
        .and_then(Value::as_str)
        .map(str::to_string)
        .ok_or_else(|| "missing required `id` parameter".to_string())
}

async fn handle_agents_remove(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let id = extract_agent_id(req)?;
    let supervisor = state.supervisor()?;
    let removed = supervisor.remove(&id).await.map_err(|e| e.to_string())?;
    Ok(serde_json::json!({ "removed": removed }))
}

async fn handle_agents_start(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let id = extract_agent_id(req)?;
    let supervisor = state.supervisor()?;
    let agent = supervisor.start(&id).await.map_err(|e| e.to_string())?;
    serde_json::to_value(agent).map_err(|e| e.to_string())
}

async fn handle_agents_stop(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let id = extract_agent_id(req)?;
    let signal: car_registry::supervisor::StopSignal = req
        .params
        .get("signal")
        .map(|v| serde_json::from_value(v.clone()))
        .transpose()
        .map_err(|e| e.to_string())?
        .unwrap_or_default();
    let supervisor = state.supervisor()?;
    let agent = supervisor
        .stop(&id, signal)
        .await
        .map_err(|e| e.to_string())?;
    serde_json::to_value(agent).map_err(|e| e.to_string())
}

async fn handle_agents_restart(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let id = extract_agent_id(req)?;
    let supervisor = state.supervisor()?;
    let agent = supervisor.restart(&id).await.map_err(|e| e.to_string())?;
    serde_json::to_value(agent).map_err(|e| e.to_string())
}

/// Block until a managed agent reaches one of the target statuses, or the
/// timeout elapses. Params: `{ id, targets?: string[] = ["running"],
/// timeout_secs?: number = 30, poll_ms?: number = 200 }`. Returns the matching
/// `ManagedAgent`, or an error on timeout / unknown id.
async fn handle_agents_wait(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let id = extract_agent_id(req)?;
    let targets: Vec<car_registry::supervisor::AgentStatus> = match req.params.get("targets") {
        Some(v) => serde_json::from_value(v.clone())
            .map_err(|e| format!("invalid 'targets' (expected status string array): {e}"))?,
        None => vec![car_registry::supervisor::AgentStatus::Running],
    };
    let targets = if targets.is_empty() {
        vec![car_registry::supervisor::AgentStatus::Running]
    } else {
        targets
    };
    let timeout = std::time::Duration::from_secs_f64(
        req.params
            .get("timeout_secs")
            .and_then(Value::as_f64)
            .unwrap_or(30.0)
            .max(0.0),
    );
    let poll = std::time::Duration::from_millis(
        req.params
            .get("poll_ms")
            .and_then(Value::as_u64)
            .unwrap_or(200)
            .max(10),
    );
    let supervisor = state.supervisor()?;
    let agent = supervisor
        .wait_for(&id, &targets, timeout, poll)
        .await
        .map_err(|e| e.to_string())?;
    serde_json::to_value(agent).map_err(|e| e.to_string())
}

async fn handle_agents_tail_log(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    let id = extract_agent_id(req)?;
    // Honor `n` OR `lines` for the count — the CarHost UI historically
    // sent `lines: 200` while the daemon only read `n`, so the count
    // was silently ignored and the modal title lied (Parslee-ai/car#273).
    let n = req
        .params
        .get("n")
        .or_else(|| req.params.get("lines"))
        .and_then(Value::as_u64)
        .unwrap_or(100) as usize;
    let offset = req
        .params
        .get("offset")
        .and_then(Value::as_u64)
        .unwrap_or(0) as usize;
    let stream = car_registry::supervisor::LogStream::from_wire(
        req.params.get("stream").and_then(Value::as_str),
    );
    let supervisor = state.supervisor()?;
    let tail = supervisor
        .read_log(&id, stream, n, offset)
        .await
        .map_err(|e| e.to_string())?;
    serde_json::to_value(tail).map_err(|e| e.to_string())
}

// ---------------------------------------------------------------------------
// External-agent detection (Phase 1 of docs/proposals/external-agent-detection.md)
//
// Discovery surface for agentic CLIs the user has already installed and
// authenticated (Claude Code, Codex, Gemini). Read-only — no invocation
// path yet; agents.invoke_external lands in Phase 2 alongside the JSON
// stdio adapter. The cache lives in car_ffi_common::external_agents so
// the in-process FFI singletons share the same snapshot.
// ---------------------------------------------------------------------------

async fn handle_agents_list_external(req: &JsonRpcMessage) -> Result<Value, String> {
    let include_health = req
        .params
        .get("include_health")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    let json = car_ffi_common::external_agents::list(include_health).await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

async fn handle_agents_detect_external(req: &JsonRpcMessage) -> Result<Value, String> {
    let include_health = req
        .params
        .get("include_health")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    let json = car_ffi_common::external_agents::detect(include_health).await?;
    serde_json::from_str(&json).map_err(|e| e.to_string())
}

#[derive(Debug, Deserialize)]
struct AssistantInvokeParams {
    capability: String,
    #[serde(default)]
    agent_hint: Option<String>,
    payload_json: String,
}

const ASSISTANT_INVOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);

/// Daemon-owned invocation for ready-to-use assistants. This mirrors the
/// UniFFI `invoke_capability` contract, but brackets the model call in the
/// durable run trace store so CarHost's Activity tab can explain what ran.
async fn handle_assistants_invoke(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
    host_session: &Arc<crate::session::ClientSession>,
) -> Result<Value, String> {
    let params: AssistantInvokeParams =
        serde_json::from_value(req.params.clone()).map_err(|e| {
            format!("assistants.invoke requires {{ capability, agent_hint?, payload_json }}: {e}")
        })?;
    let capability = params.capability.trim();
    if capability.is_empty() {
        return Err("assistants.invoke requires a non-empty `capability`".to_string());
    }

    let registry = car_engine::AgentCapabilityRegistry::new();
    car_engine::register_builtins(&registry);
    let agent = registry
        .select(capability, params.agent_hint.as_deref())
        .ok_or_else(|| format!("no ready-to-use assistant supports capability `{capability}`"))?;
    let task = car_engine::format_capability_payload(capability, &params.payload_json)
        .map_err(|e| e.to_string())?;
    let system_prompt = car_engine::agent_metadata(&agent)
        .map(|m| m.system_prompt.to_string())
        .unwrap_or_else(|| "You are a helpful assistant. Carry out the user's task.".into());

    let run_id = uuid::Uuid::new_v4().to_string();
    let started_at = chrono::Utc::now();
    state
        .start_run(crate::session::RunMeta {
            run_id: run_id.clone(),
            agent_id: agent.clone(),
            client_id: host_session.client_id.clone(),
            intent: task.clone(),
            outcome_description: Some(format!("Run {capability} with a ready-to-use assistant")),
            started_at,
            termination: None,
            ended_at: None,
            turns: Vec::new(),
        })
        .await;

    let req = car_inference::GenerateRequest {
        prompt: task.clone(),
        context: Some(system_prompt),
        intent: Some(car_inference::IntentHint {
            prefer_fast: true,
            ..Default::default()
        }),
        ..Default::default()
    };
    let engine = get_inference_engine(state);
    let generated: Result<String, String> =
        match assistant_model_setup_message(&engine, &req).await {
            Some(message) => Err(message),
            None => match tokio::time::timeout(ASSISTANT_INVOKE_TIMEOUT, engine.generate(req)).await
            {
                Ok(Ok(result)) => Ok(result),
                Ok(Err(e)) => Err(format!("Assistant failed: {e}")),
                Err(_) => Err(format!(
                    "Assistant timed out after {} seconds. Choose a faster model in Models, or try again after model setup finishes.",
                    ASSISTANT_INVOKE_TIMEOUT.as_secs()
                )),
            },
        };

    match generated {
        Ok(result) => {
            registry.note_used(capability, &agent);
            let _ = state
                .record_run_turns(
                    &run_id,
                    vec![car_proto::RunRecord::Turn(car_proto::RunTurn {
                        index: 0,
                        prompt: Some(task),
                        tool: Some(capability.to_string()),
                        parameters: serde_json::from_str(&params.payload_json).unwrap_or_else(
                            |_| serde_json::json!({ "payload_json": params.payload_json }),
                        ),
                        output: Some(serde_json::json!({ "text": result })),
                        cli_outcome: None,
                        verifier_verdict: car_proto::VerifierVerdict::NotRun,
                        policy_rejected: None,
                    })],
                )
                .await;
            state
                .complete_run(
                    &run_id,
                    car_proto::RunTermination::Outcome {
                        status: car_ir::OutcomeStatus::Success,
                        outcome: car_ir::AgentOutcome {
                            status: car_ir::OutcomeStatus::Success,
                            summary: "Assistant completed successfully.".to_string(),
                            evidence: Vec::new(),
                            metrics: Default::default(),
                            timestamp: chrono::Utc::now(),
                        },
                    },
                )
                .await?;
            Ok(serde_json::json!({
                "agent": agent,
                "result": result,
                "run_id": run_id,
            }))
        }
        Err(message) => {
            let _ = state
                .record_run_turns(
                    &run_id,
                    vec![car_proto::RunRecord::Turn(car_proto::RunTurn {
                        index: 0,
                        prompt: Some(task),
                        tool: Some(capability.to_string()),
                        parameters: serde_json::from_str(&params.payload_json).unwrap_or_else(
                            |_| serde_json::json!({ "payload_json": params.payload_json }),
                        ),
                        output: Some(serde_json::json!({ "error": message.clone() })),
                        cli_outcome: None,
                        verifier_verdict: car_proto::VerifierVerdict::NotRun,
                        policy_rejected: None,
                    })],
                )
                .await;
            state
                .complete_run(
                    &run_id,
                    car_proto::RunTermination::Outcome {
                        status: car_ir::OutcomeStatus::Failure,
                        outcome: car_ir::AgentOutcome {
                            status: car_ir::OutcomeStatus::Failure,
                            summary: message.clone(),
                            evidence: Vec::new(),
                            metrics: Default::default(),
                            timestamp: chrono::Utc::now(),
                        },
                    },
                )
                .await?;
            Err(message)
        }
    }
}

async fn assistant_model_setup_message(
    engine: &car_inference::InferenceEngine,
    req: &car_inference::GenerateRequest,
) -> Option<String> {
    let decision = engine
        .route_adaptive_with_intent(&req.prompt, req.intent.clone())
        .await;
    match engine
        .unified_registry
        .ready_without_download(&decision.model_id)
    {
        Some(true) => None,
        Some(false) => Some(format!(
            "Assistant needs model setup before it can run. Open Models and install {}, then try again.",
            decision.model_name
        )),
        None => Some(format!(
            "Assistant selected {}, but that model is not registered. Open Models and choose an available model.",
            decision.model_name
        )),
    }
}

/// Per-task invocation of an external CLI agent. Required params:
/// `id` (adapter, e.g. `"claude-code"`) and `task` (the prompt).
/// Optional: `cwd`, `allowed_tools`, `max_turns`, `timeout_secs`.
///
/// Phase 2 stage 3 ships with `claude-code` only. Other adapter
/// ids return `is_error: true` with a structured `error` so hosts
/// can surface the gap without a separate error code.
///
/// Phase 2 stage 4a (governance): every invocation appends a
/// structured audit record to `~/.car/external-agents.jsonl`. The
/// record captures id, task, options, result, and the full
/// `tool_uses` list the assistant emitted — so even though the
/// agent executes its built-in tools in-process (which we can't
/// gate via stream-json), there's a complete after-the-fact audit
/// trail. Full policy gating (proposing each tool_use to CAR's
/// validator + getting a yes/no) requires the MCP server route in
/// stage 4b.
async fn handle_agents_invoke_external(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
    host_session: &Arc<crate::session::ClientSession>,
) -> Result<Value, String> {
    let id = req
        .params
        .get("id")
        .and_then(Value::as_str)
        .ok_or_else(|| "missing required `id` parameter".to_string())?
        .to_string();
    let task = req
        .params
        .get("task")
        .and_then(Value::as_str)
        .ok_or_else(|| "missing required `task` parameter".to_string())?
        .to_string();
    let stream = req
        .params
        .get("stream")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    let session_id = req
        .params
        .get("session_id")
        .and_then(Value::as_str)
        .map(str::to_string)
        .unwrap_or_else(|| format!("ext-{}", uuid::Uuid::new_v4().simple()));

    // Build the options sub-object directly from req.params so
    // hosts can pass `cwd` / `allowed_tools` / `max_turns` /
    // `timeout_secs` as siblings of `id`/`task`. Strip the
    // dispatch + streaming fields so they don't pollute the
    // options serde.
    let mut options_value = req.params.clone();
    if let Some(obj) = options_value.as_object_mut() {
        obj.remove("id");
        obj.remove("task");
        obj.remove("stream");
        obj.remove("session_id");
        // Auto-fill `mcp_endpoint` from the bound MCP URL when the
        // caller didn't supply one. This is the load-bearing
        // wiring of MCP-4: external agents get CAR's tools (memory,
        // skills, verify) routed through the daemon's policy +
        // shared memgine without any per-call host configuration.
        // Callers who want to opt out can pass `"mcp_endpoint": ""`
        // (empty string) — the runner skips the temp-file write
        // when the value isn't a non-empty URL.
        let has_explicit_mcp = obj.contains_key("mcp_endpoint");
        if !has_explicit_mcp {
            if let Some(url) = state.mcp_url.get() {
                obj.insert("mcp_endpoint".to_string(), Value::String(url.clone()));
            }
        }
    }

    if !stream {
        // Legacy one-shot path. Unchanged shape for FFI consumers
        // and any caller that hasn't opted into streaming.
        let options_json = options_value.to_string();
        let json = car_ffi_common::external_agents::invoke(&id, &task, &options_json).await?;
        let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
        append_external_agent_audit(&id, &task, &options_value, &result);
        return Ok(result);
    }

    // Streaming path. Returns an ack ({accepted, session_id})
    // immediately and streams `agents.chat.event` notifications
    // to the host's WS as the runner emits StreamEvents. Reuses
    // the chat_sessions routing infrastructure supervised agents
    // use — host UIs render both kinds through the same path.
    let opts: car_external_agents::InvokeOptions = serde_json::from_value(options_value.clone())
        .map_err(|e| format!("invalid options: {e}"))?;

    // Register the chat session BEFORE spawning so the host's
    // subscriber is correctly bound to this session_id by the
    // time the first event arrives. Reusing the supervised
    // agent chat infrastructure means `agents.chat.cancel`
    // routes to chat_sessions[session_id] and can find this
    // entry — though we don't currently honor cancel for
    // external invocations (the child process is killed only
    // on timeout or task drop; a future iteration can plumb a
    // CancellationToken through invoke_with_emitter).
    {
        // If a chat session is already registered for this id (the
        // typical proxy shape: host → agents.chat → supervised agent
        // → agents.invoke_external with the same session_id), DO NOT
        // overwrite it. The existing entry owns the routing to the
        // original host; clobbering it with our caller's client_id
        // would send streaming events to the proxying agent instead
        // of back to the host that issued agents.chat. Only register
        // a fresh entry when the slot is empty (a direct
        // host-to-invoke_external call without a prior agents.chat).
        let mut chats = state.chat_sessions.lock().await;
        chats.entry(session_id.clone()).or_insert_with(|| {
            let created_at = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_secs())
                .unwrap_or(0);
            crate::session::ChatSession {
                agent_id: id.clone(),
                host_client_id: host_session.client_id.clone(),
                created_at,
            }
        });
    }

    // Single drain task pulls StreamEvents off an unbounded
    // channel and serializes WS sends to the host. Per-event
    // tokio::spawn would let sends race (which token arrives
    // first depends on lock acquisition order). The channel
    // is unbounded because claude's event volume is bounded
    // by user turn count — typically <50 events per invocation.
    use tokio::sync::mpsc;
    let (tx, mut rx) = mpsc::unbounded_channel::<car_external_agents::StreamEvent>();

    let drain_state = state.clone();
    let drain_session_id = session_id.clone();
    let drain_agent_id = id.clone();
    tokio::spawn(async move {
        while let Some(event) = rx.recv().await {
            emit_external_chat_event(&drain_state, &drain_session_id, &drain_agent_id, event).await;
        }
    });

    let emitter_tx = tx.clone();
    let emitter: car_external_agents::StreamEventEmitter = Arc::new(move |event| {
        // Send failure (rx dropped) means the drain task has
        // exited — usually because the host disconnected. The
        // runner will keep going; let it finish so the audit
        // log captures the full result.
        let _ = emitter_tx.send(event);
    });

    // Run the invocation in a separate task so this handler
    // can return the ack right away. The runner's child-process
    // future owns the spawn lifetime; if the host disconnects
    // mid-stream, the runner still completes (its events fall
    // on the floor at the drain layer) so the audit log lands.
    let spawn_state = state.clone();
    let spawn_session_id = session_id.clone();
    let spawn_id = id.clone();
    let spawn_task = task.clone();
    let spawn_options = options_value.clone();
    tokio::spawn(async move {
        let outcome =
            car_external_agents::invoke_with_emitter(&spawn_id, &spawn_task, opts, Some(emitter))
                .await;
        drop(tx); // signal drain task to exit after queue empties

        // Synthesize a terminal agents.chat.event so the host's
        // bubble finalizes. The runner doesn't emit a "done" event
        // itself — the result is the aggregate InvokeResult. We
        // translate it here.
        let terminal_params: Value;
        let result_value: Value;
        match outcome {
            Ok(res) => {
                // Pack the metadata into `finish_reason` as a
                // human-readable summary so the host's existing
                // ChatEvent decoder surfaces it without a schema
                // change. Hosts that want structured data can
                // re-issue `agents.invoke_external` with
                // `stream: false` and read the InvokeResult.
                let mut parts: Vec<String> = Vec::new();
                if res.turns > 0 {
                    parts.push(format!(
                        "{} turn{}",
                        res.turns,
                        if res.turns == 1 { "" } else { "s" }
                    ));
                }
                if res.tool_calls > 0 {
                    parts.push(format!(
                        "{} tool{}",
                        res.tool_calls,
                        if res.tool_calls == 1 { "" } else { "s" }
                    ));
                }
                if res.duration_ms > 0 {
                    parts.push(format!("{:.1}s", res.duration_ms as f64 / 1000.0));
                }
                if res.dropped_attachments > 0 {
                    parts.push(format!(
                        "{} image{} skipped",
                        res.dropped_attachments,
                        if res.dropped_attachments == 1 {
                            ""
                        } else {
                            "s"
                        }
                    ));
                }
                let summary = if parts.is_empty() {
                    "stop".to_string()
                } else {
                    parts.join(" · ")
                };
                if res.is_error {
                    terminal_params = serde_json::json!({
                        "session_id": spawn_session_id,
                        "agent_id": spawn_id,
                        "kind": "error",
                        "error": res.error.clone().unwrap_or_else(|| "external agent reported error".to_string()),
                    });
                } else {
                    terminal_params = serde_json::json!({
                        "session_id": spawn_session_id,
                        "agent_id": spawn_id,
                        "kind": "done",
                        "finish_reason": summary,
                        // Structured count so the host can warn distinctly
                        // from the human-readable summary. Omitted when 0.
                        "dropped_attachments": res.dropped_attachments,
                    });
                }
                result_value = serde_json::to_value(&res).unwrap_or(Value::Null);
            }
            Err(e) => {
                let message = format!("{e}");
                terminal_params = serde_json::json!({
                    "session_id": spawn_session_id,
                    "agent_id": spawn_id,
                    "kind": "error",
                    "error": message.clone(),
                });
                result_value = serde_json::json!({ "is_error": true, "error": message });
            }
        }
        send_external_chat_frame(&spawn_state, &spawn_session_id, terminal_params).await;
        spawn_state
            .chat_sessions
            .lock()
            .await
            .remove(&spawn_session_id);
        append_external_agent_audit(&spawn_id, &spawn_task, &spawn_options, &result_value);
    });

    Ok(serde_json::json!({
        "accepted": true,
        "session_id": session_id,
    }))
}

/// Translate one [`StreamEvent`] from the running external CLI
/// into an `agents.chat.event` notification on the originating
/// host's WS. Same wire shape supervised agents emit, so host
/// UIs render both kinds with one decoder.
///
/// Mapping:
/// - `Assistant` events with `text` content blocks → `kind: "token"`
///   per text block. Each block carries the full text the
///   assistant emitted in that turn (claude doesn't expose
///   word-level deltas via stream-json — it emits per-turn or
///   per-content-block chunks).
/// - `Assistant` events with `tool_use` blocks → `kind: "tool_call"`
///   per block (tool name in `detail`).
/// - `System` / `User` / `Result` / others → dropped (Result's
///   metadata is folded into the terminal `done` event the
///   outer task emits when the invocation finishes).
async fn emit_external_chat_event(
    state: &Arc<ServerState>,
    session_id: &str,
    agent_id: &str,
    event: car_external_agents::StreamEvent,
) {
    use car_external_agents::StreamEvent;
    match event {
        StreamEvent::Assistant(a) => {
            if let Some(content) = a.message.get("content").and_then(|v| v.as_array()) {
                for block in content {
                    let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
                    match block_type {
                        "text" => {
                            if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
                                if !text.is_empty() {
                                    let params = serde_json::json!({
                                        "session_id": session_id,
                                        "agent_id": agent_id,
                                        "kind": "token",
                                        "delta": text,
                                    });
                                    send_external_chat_frame(state, session_id, params).await;
                                }
                            }
                        }
                        "tool_use" => {
                            let name = block
                                .get("name")
                                .and_then(|v| v.as_str())
                                .unwrap_or("(unknown tool)");
                            // Emit the host-protocol.md `tool` + `params`
                            // shape so the host renders a structured
                            // tool-call card (name + args). `detail` is
                            // kept as a legacy alias for older hosts that
                            // only read it.
                            let mut frame = serde_json::json!({
                                "session_id": session_id,
                                "agent_id": agent_id,
                                "kind": "tool_call",
                                "tool": name,
                                "detail": name,
                            });
                            if let Some(input) = block.get("input") {
                                if !input.is_null() {
                                    frame["params"] = input.clone();
                                }
                            }
                            send_external_chat_frame(state, session_id, frame).await;
                        }
                        _ => {}
                    }
                }
            }
        }
        _ => {
            // System (session id init), User (tool result echo),
            // Result (final aggregate — folded into terminal
            // `done` event by the outer task), RateLimitEvent,
            // Other: not surfaced to host.
        }
    }
}

/// Send a single `agents.chat.event` notification to the host
/// session bound by `session_id`. Best-effort: a missing route
/// or a closed WS is silently dropped, the runner continues so
/// the audit log lands.
async fn send_external_chat_frame(state: &Arc<ServerState>, session_id: &str, params: Value) {
    use futures::SinkExt;
    use tokio_tungstenite::tungstenite::Message;

    let host_client_id = state
        .chat_sessions
        .lock()
        .await
        .get(session_id)
        .map(|s| s.host_client_id.clone());
    let Some(host_client_id) = host_client_id else {
        return;
    };
    let host_channel = {
        let sessions = state.sessions.lock().await;
        sessions.get(&host_client_id).map(|s| s.channel.clone())
    };
    let Some(channel) = host_channel else {
        return;
    };
    let frame = serde_json::json!({
        "jsonrpc": "2.0",
        "method": "agents.chat.event",
        "params": params,
    });
    if let Ok(text) = serde_json::to_string(&frame) {
        let _ = channel
            .write
            .lock()
            .await
            .send(Message::Text(text.into()))
            .await;
    }
}

/// Append one JSONL audit record to `~/.car/external-agents.jsonl`.
/// Best-effort: a failure to open the journal must NOT fail the
/// invocation; the in-memory result already returned is the
/// authoritative answer. Logs at warn level when the write fails so
/// operators notice repeated failures.
fn append_external_agent_audit(id: &str, task: &str, options: &Value, result: &Value) {
    use std::io::Write;
    let car_dir = match std::env::var_os("HOME").map(std::path::PathBuf::from) {
        Some(home) => home.join(".car"),
        None => return,
    };
    if std::fs::create_dir_all(&car_dir).is_err() {
        return;
    }
    let path = car_dir.join("external-agents.jsonl");
    let record = serde_json::json!({
        "ts": chrono::Utc::now().to_rfc3339(),
        "adapter_id": id,
        "task": task,
        "options": options,
        "result": result,
    });
    let line = match serde_json::to_string(&record) {
        Ok(s) => s,
        Err(_) => return,
    };
    if let Ok(mut f) = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
    {
        let _ = writeln!(f, "{}", line);
    } else {
        tracing::warn!(
            path = %path.display(),
            "failed to append external-agent audit record"
        );
    }
}

/// Ground-truth health check. Optional `id` param picks one tool;
/// without it, every detected adapter is checked. `force: true`
/// bypasses the 30s per-tool TTL cache. Replaces the Phase 1
/// credential-file shape heuristic as the load-bearing signal for
/// "is this tool ready to invoke."
async fn handle_agents_health_external(req: &JsonRpcMessage) -> Result<Value, String> {
    let force = req
        .params
        .get("force")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    if let Some(id) = req.params.get("id").and_then(Value::as_str) {
        let json = car_ffi_common::external_agents::health_one(id, force).await?;
        serde_json::from_str(&json).map_err(|e| e.to_string())
    } else {
        let json = car_ffi_common::external_agents::health(force).await?;
        serde_json::from_str(&json).map_err(|e| e.to_string())
    }
}

// ---------------------------------------------------------------------------
// agents.chat — unified chat surface (docs/proposals/agent-chat-surface.md)
// ---------------------------------------------------------------------------
//
// Host calls `agents.chat { agent_id, prompt, session_id?, stream? }`.
// The server looks up the target agent's attached WS connection,
// reverse-calls `agent.chat { session_id, prompt, context }` on it
// (same pattern as `tools.execute`), and returns once the agent acks.
// The agent then streams `agent.chat.event` notifications back, which
// the dispatcher intercepts (see `try_forward_agent_chat_event`) and
// rewrites as `agents.chat.event` notifications on the originating
// host's channel.

/// Timeout the server waits for the agent to ack `agent.chat`. The
/// streamed tokens come later as separate notifications and have no
/// bearing on this — this is just "did the agent receive the prompt
/// and accept it." Five seconds is generous for a local IPC ack.
const AGENT_CHAT_ACK_TIMEOUT_SECS: u64 = 5;

/// `agents.chat` — host issues a chat turn to a named agent. Returns
/// `{ accepted: true, session_id }` once the agent acks; streamed
/// tokens arrive on the host's channel as `agents.chat.event`
/// notifications keyed by the same `session_id`.
/// Validate and extract the optional `attachments` array from an
/// `agents.chat` request's params. Returns `Ok(None)` when absent or
/// null; `Err` on a malformed shape (a non-array, or any entry lacking
/// an image-`ContentBlock` `type`). Pulled out so the validation is
/// unit-testable without the full session machinery.
fn extract_chat_attachments(params: &Value) -> Result<Option<Value>, String> {
    const ALLOWED_MEDIA: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
    let items = match params.get("attachments") {
        None | Some(Value::Null) => return Ok(None),
        Some(Value::Array(items)) => items,
        Some(_) => return Err("`agents.chat` `attachments` must be an array".to_string()),
    };
    // Validate each entry's content, not just its `type` tag — we
    // forward this verbatim to the agent and on to a provider, so a
    // malformed/hostile entry (missing data, a non-image media type, a
    // `file://` URL) must not pass through.
    for item in items {
        let ty = item.get("type").and_then(Value::as_str).ok_or_else(|| {
            "`agents.chat` `attachments` entries must have a string `type`".to_string()
        })?;
        match ty {
            "image_base64" => {
                if item.get("data").and_then(Value::as_str).is_none() {
                    return Err("`image_base64` attachment requires a string `data`".to_string());
                }
                let media = item.get("media_type").and_then(Value::as_str).unwrap_or("");
                if !ALLOWED_MEDIA.contains(&media) {
                    return Err(format!(
                        "`image_base64` attachment `media_type` must be one of {ALLOWED_MEDIA:?}"
                    ));
                }
            }
            "image_url" => {
                let url = item.get("url").and_then(Value::as_str).unwrap_or("");
                if !(url.starts_with("https://") || url.starts_with("http://")) {
                    return Err("`image_url` attachment `url` must be http(s)".to_string());
                }
            }
            other => {
                return Err(format!(
                    "`agents.chat` `attachments` entries must be image ContentBlocks \
                     (`image_base64` or `image_url`), got `{other}`"
                ));
            }
        }
    }
    Ok(Some(Value::Array(items.clone())))
}

/// Build the `params` object for the reverse `agent.chat` call. The
/// `attachments` key is inserted only when present, so agents that
/// don't read it see no change to the request shape. Pulled out so the
/// attachment-forwarding is unit-testable.
fn build_agent_chat_params(
    session_id: &str,
    prompt: &str,
    stream: bool,
    host_client_id: &str,
    voice_input: bool,
    attachments: Option<Value>,
) -> Value {
    let mut params = serde_json::json!({
        "session_id": session_id,
        "prompt": prompt,
        "stream": stream,
        "context": {
            "host_client_id": host_client_id,
            "voice_input": voice_input,
        },
    });
    if let Some(att) = attachments {
        if let Some(obj) = params.as_object_mut() {
            obj.insert("attachments".to_string(), att);
        }
    }
    params
}

async fn handle_agents_chat(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
    host_session: &Arc<crate::session::ClientSession>,
) -> Result<Value, String> {
    use futures::SinkExt;
    use tokio::sync::oneshot;
    use tokio_tungstenite::tungstenite::Message;

    let agent_id = req
        .params
        .get("agent_id")
        .and_then(Value::as_str)
        .ok_or_else(|| "`agents.chat` requires `agent_id`".to_string())?
        .to_string();
    let prompt = req
        .params
        .get("prompt")
        .and_then(Value::as_str)
        .ok_or_else(|| "`agents.chat` requires `prompt`".to_string())?
        .to_string();
    let session_id = req
        .params
        .get("session_id")
        .and_then(Value::as_str)
        .map(str::to_string)
        .unwrap_or_else(|| format!("chat-{}", uuid::Uuid::new_v4().simple()));
    let stream = req
        .params
        .get("stream")
        .and_then(Value::as_bool)
        .unwrap_or(true);
    let voice_input = req
        .params
        .get("voice_input")
        .and_then(Value::as_bool)
        .unwrap_or(false);

    // Optional image attachments (ContentBlock image variants). The
    // daemon is a pass-through here — it validates the shape lightly and
    // forwards them verbatim to the agent's `agent.chat` handler, which
    // hands them to inference as `imagesJson`.
    let attachments = extract_chat_attachments(&req.params)?;

    // Resolve the agent's attached WS channel via `attached_agents` →
    // `sessions` → `channel`. Both lookups must hit; a missing entry on
    // either side means the agent is registered in `agents.json` but
    // hasn't `session.auth`'d (or has disconnected), so refuse with a
    // structured error rather than silently parking the chat.
    let agent_client_id = state
        .attached_agents
        .lock()
        .await
        .get(&agent_id)
        .cloned()
        .ok_or_else(|| {
            format!(
                "agent `{}` is not attached to this daemon — supervisor may have it stopped, or it hasn't called session.auth yet",
                agent_id
            )
        })?;
    let agent_channel = {
        let sessions = state.sessions.lock().await;
        sessions
            .get(&agent_client_id)
            .map(|s| s.channel.clone())
            .ok_or_else(|| {
                format!(
                    "agent `{}` client_id `{}` not found in session registry (raced with disconnect)",
                    agent_id, agent_client_id
                )
            })?
    };

    // Register the chat session BEFORE sending the reverse call so any
    // `agent.chat.event` notifications the agent sends as part of
    // accepting the chat (e.g. an immediate `token` delta) route
    // correctly. Indexed by session_id so the notification interceptor
    // can locate the originating host without scanning.
    {
        let created_at = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        state.chat_sessions.lock().await.insert(
            session_id.clone(),
            crate::session::ChatSession {
                agent_id: agent_id.clone(),
                host_client_id: host_session.client_id.clone(),
                created_at,
            },
        );
    }

    // Reverse-callback: register a oneshot for the ack, send the
    // `agent.chat` JSON-RPC request on the agent's channel, await up
    // to AGENT_CHAT_ACK_TIMEOUT_SECS. Uses the same `pending` map the
    // tool-callback path uses (`WsToolExecutor`) — the dispatcher's
    // response demuxer at the top of `run_dispatch` already routes
    // `result` / `error` frames keyed by request id back through it.
    let request_id = agent_channel.next_request_id();
    let (tx, rx) = oneshot::channel();
    agent_channel
        .pending
        .lock()
        .await
        .insert(request_id.clone(), tx);

    let chat_params = build_agent_chat_params(
        &session_id,
        &prompt,
        stream,
        &host_session.client_id,
        voice_input,
        attachments,
    );
    let rpc_request = serde_json::json!({
        "jsonrpc": "2.0",
        "method": "agent.chat",
        "params": chat_params,
        "id": request_id,
    });
    let msg = Message::Text(
        serde_json::to_string(&rpc_request)
            .map_err(|e| e.to_string())?
            .into(),
    );
    if let Err(e) = agent_channel.write.lock().await.send(msg).await {
        // Send failed — drop the pending waiter and the chat session
        // entry so a retry can take a fresh session_id without
        // colliding.
        agent_channel.pending.lock().await.remove(&request_id);
        state.chat_sessions.lock().await.remove(&session_id);
        return Err(format!(
            "failed to deliver agent.chat to `{}`: {}",
            agent_id, e
        ));
    }

    // Await the agent's ack. The dispatcher's response demuxer routes
    // the result/error back via the oneshot. Timeout means the agent
    // is alive but unresponsive — clean up routing state and surface a
    // structured error so the host UI doesn't hang.
    let ack = match tokio::time::timeout(
        std::time::Duration::from_secs(AGENT_CHAT_ACK_TIMEOUT_SECS),
        rx,
    )
    .await
    {
        Ok(Ok(resp)) => resp,
        Ok(Err(_)) => {
            // Channel closed — agent disconnected mid-call.
            state.chat_sessions.lock().await.remove(&session_id);
            return Err(format!(
                "agent `{}` disconnected before acking agents.chat",
                agent_id
            ));
        }
        Err(_) => {
            // Timeout — agent didn't respond in time. Don't keep the
            // chat session around: any later events from the agent
            // would route to a host that already returned an error.
            agent_channel.pending.lock().await.remove(&request_id);
            state.chat_sessions.lock().await.remove(&session_id);
            return Err(format!(
                "agent `{}` did not ack agents.chat within {}s",
                agent_id, AGENT_CHAT_ACK_TIMEOUT_SECS
            ));
        }
    };

    if let Some(err) = ack.error {
        // Agent explicitly rejected — drop the session and propagate.
        state.chat_sessions.lock().await.remove(&session_id);
        return Err(format!("agent `{}` rejected chat: {}", agent_id, err));
    }

    Ok(serde_json::json!({
        "accepted": true,
        "session_id": session_id,
    }))
}

/// Overall deadline for an A2A conversational turn: how long [`chat_collect`]
/// waits for the host agent to finish streaming after it acks. Generous (the
/// host may run several tool round-trips) but bounded so an A2A task can't hang
/// forever on a wedged agent.
const A2A_CHAT_TIMEOUT_SECS: u64 = 180;

/// Reverse-call `agent.chat` on `host_channel` and aggregate the streamed
/// `agent.chat.event` deltas into a single reply string. The A2A conversational
/// bridge uses this — it has no host UI to stream to, so it registers an
/// in-process collector (keyed by a fresh `session_id`) that
/// [`try_forward_agent_chat_event`] feeds, then accumulates `token` deltas until
/// the terminal `done`/`error`. Always removes its collector on the way out.
pub(crate) async fn chat_collect(
    state: &Arc<ServerState>,
    host_channel: &Arc<crate::session::WsChannel>,
    host_client_id: &str,
    prompt: &str,
) -> Result<String, String> {
    use futures::SinkExt;
    use tokio::sync::{mpsc, oneshot};
    use tokio_tungstenite::tungstenite::Message;

    let session_id = format!("a2a-chat-{}", uuid::Uuid::new_v4().simple());
    let (chunk_tx, mut chunk_rx) = mpsc::unbounded_channel::<crate::session::ChatStreamChunk>();
    state.chat_collectors.lock().await.insert(
        session_id.clone(),
        crate::session::ChatCollector {
            tx: chunk_tx,
            host_client_id: host_client_id.to_string(),
        },
    );

    // Ack round-trip reuses the tools.execute pending-response demuxer.
    let request_id = host_channel.next_request_id();
    let (ack_tx, ack_rx) = oneshot::channel();
    host_channel
        .pending
        .lock()
        .await
        .insert(request_id.clone(), ack_tx);

    let req = serde_json::json!({
        "jsonrpc": "2.0",
        "method": "agent.chat",
        "params": {
            "session_id": session_id,
            "prompt": prompt,
            "stream": true,
            "context": { "source": "a2a" },
        },
        "id": request_id,
    });
    if let Err(e) = host_channel
        .write
        .lock()
        .await
        .send(Message::Text(
            serde_json::to_string(&req).unwrap_or_default().into(),
        ))
        .await
    {
        host_channel.pending.lock().await.remove(&request_id);
        state.chat_collectors.lock().await.remove(&session_id);
        return Err(format!("failed to deliver agent.chat to host: {e}"));
    }

    // Wait for the host to ack (same 5s budget as agents.chat).
    let ack = tokio::time::timeout(
        std::time::Duration::from_secs(AGENT_CHAT_ACK_TIMEOUT_SECS),
        ack_rx,
    )
    .await;
    match ack {
        Ok(Ok(resp)) => {
            if let Some(err) = resp.error {
                state.chat_collectors.lock().await.remove(&session_id);
                return Err(format!("host agent rejected chat: {err}"));
            }
        }
        Ok(Err(_)) => {
            state.chat_collectors.lock().await.remove(&session_id);
            return Err("host agent disconnected before acking agent.chat".to_string());
        }
        Err(_) => {
            host_channel.pending.lock().await.remove(&request_id);
            state.chat_collectors.lock().await.remove(&session_id);
            return Err(format!(
                "host agent did not ack agent.chat within {AGENT_CHAT_ACK_TIMEOUT_SECS}s"
            ));
        }
    }

    // Aggregate streamed deltas until the terminal event or the overall timeout.
    let collect = async {
        let mut text = String::new();
        while let Some(chunk) = chunk_rx.recv().await {
            match chunk.kind.as_str() {
                "token" => {
                    if let Some(d) = chunk.delta {
                        text.push_str(&d);
                    }
                }
                "done" => return Ok(text),
                "error" => {
                    return Err(chunk
                        .error
                        .unwrap_or_else(|| "host agent error".to_string()))
                }
                _ => {}
            }
        }
        Err("agent.chat stream ended before completion".to_string())
    };
    let result = tokio::time::timeout(
        std::time::Duration::from_secs(A2A_CHAT_TIMEOUT_SECS),
        collect,
    )
    .await;
    state.chat_collectors.lock().await.remove(&session_id);
    match result {
        Ok(r) => r,
        Err(_) => Err(format!(
            "host agent did not complete the reply within {A2A_CHAT_TIMEOUT_SECS}s"
        )),
    }
}

/// [`car_a2a::ChatResponder`] backed by the WS session that started the A2A
/// listener: a conversational A2A `message/send` reverse-calls `agent.chat` on
/// that session and returns the host agent's aggregated reply. Holds a `Weak`
/// to `ServerState` so a dropped daemon doesn't keep it alive, and resolves the
/// host channel fresh each call so a reconnect/disconnect surfaces as an error.
pub(crate) struct WsChatResponder {
    pub state: std::sync::Weak<ServerState>,
    pub host_client_id: String,
}

#[async_trait::async_trait]
impl car_a2a::ChatResponder for WsChatResponder {
    async fn respond(&self, prompt: &str) -> Result<String, String> {
        let state = self
            .state
            .upgrade()
            .ok_or_else(|| "daemon is shutting down".to_string())?;
        let host_channel = {
            let sessions = state.sessions.lock().await;
            sessions
                .get(&self.host_client_id)
                .map(|s| s.channel.clone())
        }
        .ok_or_else(|| "host session for the A2A listener has disconnected".to_string())?;
        chat_collect(&state, &host_channel, &self.host_client_id, prompt).await
    }
}

/// `agents.chat.cancel` — host aborts an in-flight chat. Forwards
/// `agent.chat.cancel` to the bound agent so the agent can short-
/// circuit its inference stream + free upstream resources
/// (`inference.stream.cancel`). The chat session is dropped from
/// routing state immediately whether or not the agent acks the cancel
/// — further `agent.chat.event` notifications for this session_id
/// fall on the floor by design.
async fn handle_agents_chat_cancel(
    req: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> Result<Value, String> {
    use futures::SinkExt;
    use tokio_tungstenite::tungstenite::Message;

    let session_id = req
        .params
        .get("session_id")
        .and_then(Value::as_str)
        .ok_or_else(|| "`agents.chat.cancel` requires `session_id`".to_string())?
        .to_string();

    let chat = state.chat_sessions.lock().await.remove(&session_id);
    let chat = match chat {
        Some(c) => c,
        None => {
            // Already cancelled or never existed — idempotent.
            return Ok(serde_json::json!({ "cancelled": false, "reason": "unknown session_id" }));
        }
    };

    // Best-effort fire-and-forget to the agent. We've already removed
    // the routing entry, so no need to await any agent response.
    let agent_client_id = state
        .attached_agents
        .lock()
        .await
        .get(&chat.agent_id)
        .cloned();
    if let Some(client_id) = agent_client_id {
        let channel_opt = {
            let sessions = state.sessions.lock().await;
            sessions.get(&client_id).map(|s| s.channel.clone())
        };
        if let Some(channel) = channel_opt {
            let notification = serde_json::json!({
                "jsonrpc": "2.0",
                "method": "agent.chat.cancel",
                "params": { "session_id": session_id },
            });
            if let Ok(text) = serde_json::to_string(&notification) {
                let _ = channel
                    .write
                    .lock()
                    .await
                    .send(Message::Text(text.into()))
                    .await;
            }
        }
    }

    Ok(serde_json::json!({ "cancelled": true, "session_id": session_id }))
}

/// Forward an `agent.chat.event` notification from an agent's
/// connection to the originating host's connection, rewritten as an
/// `agents.chat.event` notification. Returns `true` if the inbound
/// frame was a chat-event we routed (so the dispatcher can `continue`
/// past the normal method dispatch and skip the wasted "unknown
/// method" response), `false` otherwise.
///
/// Terminal events (`kind: "done"` / `"error"`) also drop the routing
/// entry from `state.chat_sessions` so a later stray notification can
/// be rejected as orphaned without leaking memory.
pub(crate) async fn try_forward_agent_chat_event(
    parsed: &JsonRpcMessage,
    state: &Arc<ServerState>,
) -> bool {
    use futures::SinkExt;
    use tokio_tungstenite::tungstenite::Message;

    // Notification predicate: method is `agent.chat.event`, id is
    // missing/null (per JSON-RPC, notifications have no id), and
    // params carry a session_id.
    let Some(method) = parsed.method.as_deref() else {
        return false;
    };
    if method != "agent.chat.event" {
        return false;
    }
    if !parsed.id.is_null() {
        // Has an id → it's a request, not a notification. Let the
        // normal dispatcher handle it (and reply with method-not-found).
        return false;
    }
    let Some(session_id) = parsed.params.get("session_id").and_then(Value::as_str) else {
        return false;
    };
    let session_id = session_id.to_string();

    // In-process collector path (the A2A conversational bridge has no host UI
    // to stream to). If a collector is registered for this session, feed it the
    // normalized chunk and consume the event — the collecting task owns the
    // collector's lifetime, so we don't touch chat_sessions or remove it here.
    let collector = state
        .chat_collectors
        .lock()
        .await
        .get(&session_id)
        .map(|c| c.tx.clone());
    if let Some(tx) = collector {
        let kind = parsed
            .params
            .get("kind")
            .and_then(Value::as_str)
            .map(str::to_string)
            .unwrap_or_else(|| {
                if parsed.params.get("error").is_some() {
                    "error".to_string()
                } else if parsed.params.get("finish_reason").is_some() {
                    "done".to_string()
                } else {
                    "token".to_string()
                }
            });
        let delta = parsed
            .params
            .get("delta")
            .and_then(Value::as_str)
            .map(str::to_string);
        let error = parsed
            .params
            .get("error")
            .and_then(Value::as_str)
            .map(str::to_string);
        let _ = tx.send(crate::session::ChatStreamChunk { kind, delta, error });
        return true;
    }

    // Look up the routing entry. If gone (cancelled, agent dropped,
    // disconnect cleanup), drop the event silently — late frames from
    // a respawned agent for a stale session are not the host's
    // problem.
    let chat = state.chat_sessions.lock().await.get(&session_id).cloned();
    let Some(chat) = chat else {
        return true; // recognized the method, but routing has dropped — consumed.
    };

    // Pull the kind early so terminal-event cleanup runs even if the
    // host's send fails. Agents may omit `kind` and signal terminal
    // state via `finish_reason` / `error` instead (car#222) — derive
    // it from the frame shape so both the cleanup below AND the host
    // see a correct, host-protocol-compliant kind. The old code
    // defaulted a `finish_reason`-only "done" frame to "token", so
    // terminal cleanup never ran and the host (which requires `kind`)
    // dropped every frame silently.
    let kind = parsed
        .params
        .get("kind")
        .and_then(Value::as_str)
        .map(str::to_string)
        .unwrap_or_else(|| {
            if parsed.params.get("error").is_some() {
                "error".to_string()
            } else if parsed.params.get("finish_reason").is_some() {
                "done".to_string()
            } else {
                "token".to_string()
            }
        });

    // Forward to the host. Rewrites the method name to the host-facing
    // form and attaches `agent_id` so the host doesn't have to remember
    // which agent owns each session.
    let host_channel = {
        let sessions = state.sessions.lock().await;
        sessions
            .get(&chat.host_client_id)
            .map(|s| s.channel.clone())
    };
    if let Some(channel) = host_channel {
        let mut params = parsed.params.clone();
        if let Some(obj) = params.as_object_mut() {
            obj.insert("agent_id".to_string(), Value::String(chat.agent_id.clone()));
            // host-protocol.md requires a top-level `kind` on every
            // agents.chat.event. Agents that omit it (signalling via
            // finish_reason/error) were dropped wholesale by the host
            // decoder — normalize here so the contract holds. car#222.
            obj.entry("kind")
                .or_insert_with(|| Value::String(kind.clone()));
        }
        let forward = serde_json::json!({
            "jsonrpc": "2.0",
            "method": "agents.chat.event",
            "params": params,
        });
        if let Ok(text) = serde_json::to_string(&forward) {
            let send_result = channel
                .write
                .lock()
                .await
                .send(Message::Text(text.into()))
                .await;
            if let Err(e) = send_result {
                tracing::warn!(
                    session_id = %session_id,
                    agent_id = %chat.agent_id,
                    host_client_id = %chat.host_client_id,
                    kind = %kind,
                    error = %e,
                    "agent.chat.event forward to host failed at the WS send step"
                );
            }
        }
    } else {
        // Host disconnected mid-stream — chat_sessions still holds
        // the routing entry but the originating client_id no longer
        // resolves to a session. Pre-#233 this was silent and the
        // operator had no way to tell whether the event was dropped
        // here or never emitted by the agent. Log + drop the
        // routing entry so subsequent stray events are no-ops.
        tracing::warn!(
            session_id = %session_id,
            agent_id = %chat.agent_id,
            host_client_id = %chat.host_client_id,
            kind = %kind,
            "agent.chat.event from supervised agent had no host channel \
             (host disconnected since `agents.chat`); dropping routing entry"
        );
        state.chat_sessions.lock().await.remove(&session_id);
        return true;
    }

    // Terminal-kind cleanup. The host_channel branch above already
    // forwarded the terminal event; we just remove the routing entry
    // here so subsequent stray frames are no-ops.
    if matches!(kind.as_str(), "done" | "error") {
        state.chat_sessions.lock().await.remove(&session_id);
    }

    true
}

#[cfg(test)]
mod fd_leak_regression {
    //! car#209 regression: an abrupt transport error must still run
    //! the connection cleanup. Before the fix, `let msg = msg?;`
    //! propagated the read error out of `run_dispatch`, skipping
    //! `remove_session`, so `state.sessions` (holding the
    //! `Arc<ClientSession>` -> `Arc<WsChannel>` -> socket FD) leaked
    //! forever on every peer reset / crash-loop disconnect.
    use super::run_dispatch;
    use futures::SinkExt;
    use std::sync::Arc;
    use tokio_tungstenite::tungstenite::{Error as WsError, Message};

    #[tokio::test]
    async fn abrupt_read_error_still_runs_session_cleanup() {
        let tmp = tempfile::TempDir::new().unwrap();
        let state = Arc::new(crate::session::ServerState::standalone(
            tmp.path().to_path_buf(),
        ));

        // Read stream that immediately yields a transport error (peer
        // reset), then ends -- the exact shape of an ungraceful
        // client disconnect.
        let read = futures::stream::iter(vec![Err::<Message, WsError>(WsError::ConnectionClosed)]);
        let write: crate::session::WsSink =
            Box::pin(futures::sink::drain().sink_map_err(|_| WsError::ConnectionClosed));

        let result = run_dispatch(read, write, "test-peer".to_string(), state.clone()).await;
        assert!(
            result.is_ok(),
            "run_dispatch must return Ok after cleanup, got {result:?}"
        );

        // The session (and its channel/FD) must be gone -- cleanup
        // ran despite the abrupt error.
        assert!(
            state.sessions.lock().await.is_empty(),
            "state.sessions must be empty after an abrupt disconnect (car#209)"
        );
    }
}

#[cfg(test)]
mod a2ui_action_delivery {
    //! Parslee-ai/car-releases#58: a ClientAction must reach A2UI
    //! subscribers on the `a2ui.event` channel — the same one that
    //! already carries surface updates — so an agent that created a
    //! surface receives the button click it would otherwise never see.
    use super::{handle_a2ui_action, JsonRpcMessage};
    use crate::session::{ServerState, WsChannel, WsSink};
    use futures::{SinkExt, StreamExt};
    use std::collections::HashMap;
    use std::sync::atomic::AtomicU64;
    use std::sync::Arc;
    use tokio::sync::Mutex;
    use tokio_tungstenite::tungstenite::{Error as WsError, Message};

    #[tokio::test]
    async fn client_action_broadcasts_to_a2ui_subscribers() {
        let tmp = tempfile::TempDir::new().unwrap();
        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));

        // Capturing subscriber channel: a futures mpsc whose sink half is
        // erased into the WsSink the server writes to, receiver kept here.
        let (tx, mut rx) = futures::channel::mpsc::unbounded::<Message>();
        let sink: WsSink = Box::pin(tx.sink_map_err(|_| WsError::ConnectionClosed));
        let channel = Arc::new(WsChannel {
            write: Mutex::new(sink),
            pending: Mutex::new(HashMap::new()),
            next_id: AtomicU64::new(0),
        });
        state
            .a2ui_subscribers
            .lock()
            .await
            .insert("test-sub".to_string(), channel);

        // Send the action under the `action` key (not `name`) to also
        // exercise the serde alias — the web renderer forwards
        // `@a2ui/web_core`'s object verbatim.
        let req: JsonRpcMessage = serde_json::from_value(serde_json::json!({
            "jsonrpc": "2.0",
            "method": "a2ui.action",
            "id": 1,
            "params": {
                "action": "trader:pause",
                "surfaceId": "surf-1",
                "sourceComponentId": "b1",
                "timestamp": "2026-06-03T00:00:00Z"
            }
        }))
        .unwrap();

        let out = handle_a2ui_action(&req, &state).await;
        assert!(out.is_ok(), "handle_a2ui_action failed: {out:?}");

        let msg = rx.next().await.expect("subscriber received no frame");
        let text = match msg {
            Message::Text(t) => t.to_string(),
            other => panic!("expected text frame, got {other:?}"),
        };
        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert_eq!(v["method"], "a2ui.event");
        assert_eq!(v["params"]["kind"], "a2ui.action");
        // Alias resolved `action` -> `name`.
        assert_eq!(
            v["params"]["result"]["action"]["name"], "trader:pause",
            "ClientAction.name should accept the `action` alias"
        );
        assert_eq!(v["params"]["result"]["surfaceId"], "surf-1");
    }
}

#[cfg(test)]
mod a2a_chat_collector {
    //! car-releases#65 part 2: when an A2A conversational turn registers an
    //! in-process collector, `try_forward_agent_chat_event` feeds it the
    //! normalized stream chunks instead of forwarding to a host UI channel.
    use super::{try_forward_agent_chat_event, JsonRpcMessage};
    use crate::session::{ChatCollector, ServerState};
    use std::sync::Arc;
    use tokio::sync::mpsc;

    fn event(session_id: &str, extra: serde_json::Value) -> JsonRpcMessage {
        let mut params = serde_json::Map::new();
        params.insert("session_id".into(), session_id.into());
        if let Some(obj) = extra.as_object() {
            for (k, v) in obj {
                params.insert(k.clone(), v.clone());
            }
        }
        serde_json::from_value(serde_json::json!({
            "jsonrpc": "2.0",
            "method": "agent.chat.event",
            "params": params,
        }))
        .unwrap()
    }

    #[tokio::test]
    async fn collector_receives_normalized_chunks() {
        let tmp = tempfile::TempDir::new().unwrap();
        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
        let (tx, mut rx) = mpsc::unbounded_channel();
        state.chat_collectors.lock().await.insert(
            "a2a-chat-xyz".into(),
            ChatCollector {
                tx,
                host_client_id: "host-1".into(),
            },
        );

        // Explicit token delta.
        assert!(
            try_forward_agent_chat_event(
                &event(
                    "a2a-chat-xyz",
                    serde_json::json!({ "kind": "token", "delta": "Hello " })
                ),
                &state,
            )
            .await
        );
        // kind omitted, has delta → derived "token".
        try_forward_agent_chat_event(
            &event("a2a-chat-xyz", serde_json::json!({ "delta": "world" })),
            &state,
        )
        .await;
        // kind omitted, finish_reason present → derived terminal "done".
        try_forward_agent_chat_event(
            &event(
                "a2a-chat-xyz",
                serde_json::json!({ "finish_reason": "1 turn" }),
            ),
            &state,
        )
        .await;

        let c1 = rx.recv().await.unwrap();
        assert_eq!(c1.kind, "token");
        assert_eq!(c1.delta.as_deref(), Some("Hello "));
        let c2 = rx.recv().await.unwrap();
        assert_eq!(c2.delta.as_deref(), Some("world"));
        let c3 = rx.recv().await.unwrap();
        assert_eq!(c3.kind, "done");

        // The collector is left in place for the collecting task to drain/remove.
        assert!(state
            .chat_collectors
            .lock()
            .await
            .contains_key("a2a-chat-xyz"));
    }

    #[tokio::test]
    async fn error_event_carries_error_text() {
        let tmp = tempfile::TempDir::new().unwrap();
        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
        let (tx, mut rx) = mpsc::unbounded_channel();
        state.chat_collectors.lock().await.insert(
            "a2a-chat-err".into(),
            ChatCollector {
                tx,
                host_client_id: "h".into(),
            },
        );
        try_forward_agent_chat_event(
            &event(
                "a2a-chat-err",
                serde_json::json!({ "error": "model exploded" }),
            ),
            &state,
        )
        .await;
        let c = rx.recv().await.unwrap();
        assert_eq!(c.kind, "error"); // derived from `error` presence
        assert_eq!(c.error.as_deref(), Some("model exploded"));
    }
}

#[cfg(test)]
mod scheduler_stateful_dedup {
    //! The WS `scheduler.run` path deserializes a fresh `Task` from client
    //! params on every call and used to run it through a throwaway `Executor`
    //! without persisting the mutated task. Because the executor's
    //! deterministic occurrence guard lives in `task.executions`, that history
    //! was empty on every call and a replayed Interval/Once occurrence ran
    //! twice. `run_scheduler_task_once` now seeds the task from a `TaskStore`
    //! and saves it back, so the guard survives the JSON-RPC boundary.
    use super::run_scheduler_task_once;
    use car_multi::{AgentOutput, AgentRunner, AgentSpec, Mailbox, MultiError};
    use car_scheduler::{Task, TaskStore, TaskTrigger};
    use std::sync::atomic::{AtomicU32, Ordering};
    use std::sync::Arc;

    /// Runner that counts how many times the runtime actually invoked it.
    struct CountingRunner {
        runs: Arc<AtomicU32>,
    }

    #[async_trait::async_trait]
    impl AgentRunner for CountingRunner {
        async fn run(
            &self,
            spec: &AgentSpec,
            _task: &str,
            _runtime: &car_engine::Runtime,
            _mailbox: &Mailbox,
        ) -> Result<AgentOutput, MultiError> {
            self.runs.fetch_add(1, Ordering::SeqCst);
            Ok(AgentOutput {
                name: spec.name.clone(),
                answer: "done".to_string(),
                turns: 1,
                tool_calls: 0,
                duration_ms: 1.0,
                error: None,
                outcome: None,
                tokens: None,
                tools_used: Vec::new(),
            })
        }
    }

    #[tokio::test]
    async fn second_run_dedups_across_the_jsonrpc_boundary() {
        let dir = tempfile::TempDir::new().unwrap();
        let store = TaskStore::new(dir.path());
        let runs = Arc::new(AtomicU32::new(0));

        // The same Task JSON a client would post twice: an Interval trigger on a
        // long schedule (both calls land in slot 0) with an empty execution
        // history each time. Serializing/deserializing models the JSON-RPC hop.
        let task =
            Task::new("dedup_task", "do the thing").with_trigger(TaskTrigger::Interval, "1h");
        let task_json = serde_json::to_value(&task).unwrap();

        // First call: runs and persists the occurrence.
        let mut t1: Task = serde_json::from_value(task_json.clone()).unwrap();
        assert!(t1.executions.is_empty());
        let runner1: Arc<dyn AgentRunner> = Arc::new(CountingRunner { runs: runs.clone() });
        run_scheduler_task_once(&mut t1, runner1, &store).await;

        // Second call: a fresh deserialized Task (empty executions again), but
        // the store seeds the prior occurrence so the guard dedups it.
        let mut t2: Task = serde_json::from_value(task_json).unwrap();
        assert!(t2.executions.is_empty());
        let runner2: Arc<dyn AgentRunner> = Arc::new(CountingRunner { runs: runs.clone() });
        run_scheduler_task_once(&mut t2, runner2, &store).await;

        assert_eq!(
            runs.load(Ordering::SeqCst),
            1,
            "the runner must be invoked exactly once across two stateless calls"
        );

        // The persisted task carries the single occurrence.
        let persisted = store.load(&task.id).expect("task should be persisted");
        assert_eq!(persisted.run_count, 1);
        assert_eq!(persisted.executions.len(), 1);
    }
}

#[cfg(test)]
mod a2ui_apply_delivery {
    //! Parslee-ai/car-releases#29: applying an A2UI envelope must reach
    //! every WS subscriber on the `a2ui.event` channel. A `createSurface`
    //! apply broadcasts `a2ui.surface_updated`; a `deleteSurface` apply
    //! broadcasts `a2ui.surface_deleted`. Reuses the fake
    //! WsChannel-over-mpsc harness from `a2ui_action_delivery`.
    use super::apply_a2ui_envelope;
    use crate::session::{ServerState, WsChannel, WsSink};
    use futures::{SinkExt, StreamExt};
    use std::collections::HashMap;
    use std::sync::atomic::AtomicU64;
    use std::sync::Arc;
    use tokio::sync::Mutex;
    use tokio_tungstenite::tungstenite::{Error as WsError, Message};

    fn fake_subscriber() -> (
        Arc<WsChannel>,
        futures::channel::mpsc::UnboundedReceiver<Message>,
    ) {
        let (tx, rx) = futures::channel::mpsc::unbounded::<Message>();
        let sink: WsSink = Box::pin(tx.sink_map_err(|_| WsError::ConnectionClosed));
        let channel = Arc::new(WsChannel {
            write: Mutex::new(sink),
            pending: Mutex::new(HashMap::new()),
            next_id: AtomicU64::new(0),
        });
        (channel, rx)
    }

    fn envelope(value: serde_json::Value) -> car_a2ui::A2uiEnvelope {
        serde_json::from_value(value).expect("valid envelope")
    }

    #[tokio::test]
    async fn apply_create_broadcasts_surface_updated() {
        let tmp = tempfile::TempDir::new().unwrap();
        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
        let (channel, mut rx) = fake_subscriber();
        state
            .a2ui_subscribers
            .lock()
            .await
            .insert("test-sub".to_string(), channel);

        apply_a2ui_envelope(
            &state,
            envelope(serde_json::json!({
                "version": "v0.9",
                "createSurface": { "surfaceId": "surf-a" }
            })),
            None,
            None,
        )
        .await
        .expect("apply must succeed");

        let msg = rx.next().await.expect("subscriber received no frame");
        let text = match msg {
            Message::Text(t) => t.to_string(),
            other => panic!("expected text frame, got {other:?}"),
        };
        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert_eq!(v["method"], "a2ui.event");
        assert_eq!(v["params"]["kind"], "a2ui.surface_updated");
        assert_eq!(v["params"]["result"]["surfaceId"], "surf-a");
    }

    #[tokio::test]
    async fn apply_delete_broadcasts_surface_deleted() {
        let tmp = tempfile::TempDir::new().unwrap();
        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
        let (channel, mut rx) = fake_subscriber();
        state
            .a2ui_subscribers
            .lock()
            .await
            .insert("test-sub".to_string(), channel);

        // Create the surface first so the delete targets something real.
        apply_a2ui_envelope(
            &state,
            envelope(serde_json::json!({
                "version": "v0.9",
                "createSurface": { "surfaceId": "surf-d" }
            })),
            None,
            None,
        )
        .await
        .expect("create must succeed");
        let _ = rx.next().await.expect("surface_updated frame");

        apply_a2ui_envelope(
            &state,
            envelope(serde_json::json!({
                "version": "v0.9",
                "deleteSurface": { "surfaceId": "surf-d" }
            })),
            None,
            None,
        )
        .await
        .expect("delete must succeed");

        let msg = rx
            .next()
            .await
            .expect("subscriber received no delete frame");
        let text = match msg {
            Message::Text(t) => t.to_string(),
            other => panic!("expected text frame, got {other:?}"),
        };
        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert_eq!(v["method"], "a2ui.event");
        assert_eq!(v["params"]["kind"], "a2ui.surface_deleted");
        assert_eq!(v["params"]["result"]["surfaceId"], "surf-d");
    }
}

#[cfg(test)]
mod agents_chat_attachments {
    //! Image attachments ride the `agents.chat` → `agent.chat`
    //! reverse-call as an optional `attachments` array of image
    //! ContentBlocks. The daemon validates the shape and forwards it
    //! verbatim; absent attachments must not alter the request shape.
    use super::{build_agent_chat_params, extract_chat_attachments};
    use serde_json::json;

    #[test]
    fn extract_returns_none_when_absent_or_null() {
        assert_eq!(extract_chat_attachments(&json!({"prompt": "hi"})), Ok(None));
        assert_eq!(
            extract_chat_attachments(&json!({"attachments": null})),
            Ok(None)
        );
    }

    #[test]
    fn extract_accepts_image_content_blocks() {
        let params = json!({
            "attachments": [
                {"type": "image_base64", "data": "AAAA", "media_type": "image/png"},
                {"type": "image_url", "url": "https://example.com/a.jpg", "detail": "auto"}
            ]
        });
        let got = extract_chat_attachments(&params).unwrap().unwrap();
        assert_eq!(got.as_array().map(|a| a.len()), Some(2));
    }

    #[test]
    fn extract_rejects_non_array() {
        assert!(extract_chat_attachments(&json!({"attachments": "nope"})).is_err());
    }

    #[test]
    fn extract_rejects_non_image_entry() {
        let params = json!({
            "attachments": [
                {"type": "image_base64", "data": "AAAA", "media_type": "image/png"},
                {"type": "text", "text": "not an image"}
            ]
        });
        assert!(extract_chat_attachments(&params).is_err());
    }

    #[test]
    fn extract_rejects_base64_missing_data() {
        let params = json!({"attachments": [{"type": "image_base64", "media_type": "image/png"}]});
        assert!(extract_chat_attachments(&params).is_err());
    }

    #[test]
    fn extract_rejects_disallowed_media_type() {
        let params = json!({
            "attachments": [{"type": "image_base64", "data": "AAAA", "media_type": "image/svg+xml"}]
        });
        assert!(extract_chat_attachments(&params).is_err());
    }

    #[test]
    fn extract_rejects_non_http_image_url() {
        let params = json!({"attachments": [{"type": "image_url", "url": "file:///etc/passwd"}]});
        assert!(extract_chat_attachments(&params).is_err());
    }

    #[test]
    fn build_params_omits_attachments_when_none() {
        let params = build_agent_chat_params("sess-1", "hello", true, "host-1", false, None);
        assert_eq!(params["session_id"], "sess-1");
        assert_eq!(params["prompt"], "hello");
        assert_eq!(params["context"]["host_client_id"], "host-1");
        assert!(
            params.get("attachments").is_none(),
            "no `attachments` key when there are none — agents that ignore it see the legacy shape"
        );
    }

    #[test]
    fn build_params_carries_attachments_when_present() {
        let blocks = json!([
            {"type": "image_base64", "data": "AAAA", "media_type": "image/png"}
        ]);
        let params =
            build_agent_chat_params("sess-2", "describe", true, "host-2", false, Some(blocks));
        let att = params["attachments"]
            .as_array()
            .expect("attachments present");
        assert_eq!(att.len(), 1);
        assert_eq!(att[0]["type"], "image_base64");
        assert_eq!(att[0]["media_type"], "image/png");
    }
}

#[cfg(test)]
mod tool_stream_surface {
    //! C2 WS surface: `tools.poll` drains a detached handle's chunks and
    //! reports status, `tools.cancel` seals it. An unknown handle polls to
    //! `null` (absence, not an error) and cancels to `{cancelled: false}`.
    use super::{handle_tools_cancel, handle_tools_poll, JsonRpcMessage};
    use crate::session::{ServerState, WsChannel};
    use serde_json::{json, Value};
    use std::sync::Arc;

    fn req(params: Value) -> JsonRpcMessage {
        JsonRpcMessage {
            jsonrpc: "2.0".into(),
            method: None,
            params,
            id: json!(1),
            result: None,
            error: None,
        }
    }

    #[tokio::test]
    async fn poll_and_cancel_roundtrip() {
        let tmp = tempfile::TempDir::new().unwrap();
        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
        let channel = Arc::new(WsChannel::test_stub());
        let session = state.create_session("c-tools-c2", channel).await;

        // Unknown handle → null, not an error.
        let poll = handle_tools_poll(&req(json!({"handle": "nope"})), &session)
            .await
            .unwrap();
        assert!(poll.is_null(), "unknown handle must poll to null");

        // Missing param → error (house style).
        assert!(handle_tools_poll(&req(json!({})), &session).await.is_err());

        // Start a detached invocation on the session runtime's registry —
        // the same path a detached ToolCall dispatch takes.
        let (h, _tok) = session.runtime.tool_handles.register("tail_log", "a1").await;
        session
            .runtime
            .tool_handles
            .push_chunk(&h.id, car_ir::ToolStreamChunk::Text { text: "line".into() })
            .await;

        let poll = handle_tools_poll(&req(json!({"handle": h.id})), &session)
            .await
            .unwrap();
        assert_eq!(poll["status"], "running");
        assert_eq!(poll["chunks"][0]["kind"], "text");
        assert_eq!(poll["chunks"][0]["text"], "line");
        assert_eq!(poll["tool"], "tail_log");

        // Cancel seals the invocation; a subsequent poll observes it.
        let cancel = handle_tools_cancel(&req(json!({"handle": h.id})), &session)
            .await
            .unwrap();
        assert_eq!(cancel["cancelled"], true);
        let poll = handle_tools_poll(&req(json!({"handle": h.id})), &session)
            .await
            .unwrap();
        assert_eq!(poll["status"], "cancelled");

        // Unknown handle cancels to false.
        let cancel = handle_tools_cancel(&req(json!({"handle": "nope"})), &session)
            .await
            .unwrap();
        assert_eq!(cancel["cancelled"], false);
    }
}

#[cfg(test)]
mod sync_lease_surface {
    //! The `sync.*` / `lease.*` daemon dispatch surface (B6). Drives the
    //! handlers directly against a standalone `ServerState` (rooted at a tmp dir,
    //! so the sync subsystem opens under `<tmp>/sync/`), proving the WS wiring,
    //! transcript resume, the dispatch fence, and cross-call lease visibility on
    //! the one daemon-held subsystem. The two-device convergence + fencing
    //! mechanics themselves are unit-tested in `crate::sync`.
    use super::{
        handle_lease_acquire, handle_lease_release, handle_lease_status, handle_sync_append,
        handle_sync_fence_check, handle_sync_record_intent, handle_sync_record_turn,
        handle_sync_resume, handle_sync_status, JsonRpcMessage,
    };
    use crate::session::ServerState;
    use serde_json::{json, Value};
    use std::sync::Arc;

    fn req(params: Value) -> JsonRpcMessage {
        JsonRpcMessage {
            jsonrpc: "2.0".into(),
            method: None,
            params,
            id: json!(1),
            result: None,
            error: None,
        }
    }

    #[tokio::test]
    async fn sync_and_lease_dispatch_roundtrip() {
        let tmp = tempfile::TempDir::new().unwrap();
        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));

        // sync.status lazily opens the subsystem and reports a real device id.
        let status = handle_sync_status(&state).await.unwrap();
        assert!(status["device_id"].as_str().unwrap().starts_with("device-"));

        // Record a knowledge op + a conversation turn through the oplog.
        handle_sync_append(
            &req(json!({"surface": "knowledge", "payload": {"id": "f1", "body": "sky"}})),
            &state,
        )
        .await
        .unwrap();
        handle_sync_record_turn(
            &req(json!({"conversation_id": "c1", "role": "user", "content": "hi", "timestamp": 1})),
            &state,
        )
        .await
        .unwrap();

        // sync.resume is real: the turn comes back as a provider-valid message.
        let resume = handle_sync_resume(&req(json!({"conversation_id": "c1"})), &state)
            .await
            .unwrap();
        let msgs = resume.as_array().unwrap();
        assert_eq!(msgs.len(), 1);
        assert_eq!(msgs[0]["role"], json!("user"));

        // lease.acquire → status reflects the same daemon-held holder.
        let lease = handle_lease_acquire(&req(json!({"agent_id": "milo", "ttl_ms": 1_000_000})), &state)
            .await
            .unwrap();
        assert_eq!(lease["epoch"], json!(1));
        let ls = handle_lease_status(&req(json!({"agent_id": "milo"})), &state)
            .await
            .unwrap();
        assert_eq!(ls["lease"]["holder"], lease["holder"]);

        // The holder may dispatch run r1 (no prior commit).
        let fence = handle_sync_fence_check(
            &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1})),
            &state,
        )
        .await
        .unwrap();
        assert_eq!(fence["may_dispatch"], json!(true));

        // Record r1 committed; the fence now refuses re-dispatch (idempotency).
        handle_sync_record_intent(
            &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1, "status": "committed"})),
            &state,
        )
        .await
        .unwrap();
        let fence = handle_sync_fence_check(
            &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1})),
            &state,
        )
        .await
        .unwrap();
        assert_eq!(fence["decision"]["decision"], json!("already_committed"));
        assert_eq!(fence["may_dispatch"], json!(false));

        // Clean release; a bad-epoch renew/release surfaces as an error.
        handle_lease_release(&req(json!({"agent_id": "milo", "epoch": 1})), &state)
            .await
            .unwrap();
        assert!(handle_lease_release(&req(json!({"agent_id": "milo", "epoch": 1})), &state)
            .await
            .is_err());
    }
}
