car-ffi-common 0.24.0

Shared logic for FFI bindings (NAPI, PyO3) — JSON wrappers for verify, multi-agent, scheduler
//! Proxies for the daemon's `coder.*` namespace (built-in coding agent).
//!
//! Unlike the in-process `supervisor`/`external_agents` modules, coder
//! sessions live **in the daemon** — they must be visible to CarHost and
//! survive the FFI caller's process — so every function here is a thin
//! [`DaemonClient`] call with the exact WS wire shapes. Streaming
//! (`coder.subscribe` / `coder.event`) is WebSocket-only; FFI callers that
//! want live events connect to the daemon's WS directly (same contract as
//! `infer_stream`).

use serde_json::{json, Value};

use crate::proxy::DaemonClient;

fn to_string(v: Value) -> Result<String, String> {
    serde_json::to_string(&v).map_err(|e| e.to_string())
}

/// `coder.start` — provision a worktree, derive the outcome contract.
/// `engine` is `auto | native | external[:agent_id]` (None = auto).
pub async fn start(
    client: &DaemonClient,
    repo: &str,
    intent: &str,
    engine: Option<&str>,
    max_iterations: Option<u32>,
) -> Result<String, String> {
    let mut params = json!({ "repo": repo, "intent": intent });
    if let Some(engine) = engine {
        params["engine"] = json!(engine);
    }
    if let Some(n) = max_iterations {
        params["max_iterations"] = json!(n);
    }
    client.call("coder.start", params).await.and_then(to_string)
}

/// `coder.confirm_contract` — accept (or replace with `contract_json`) the
/// proposed contract and start the work loop.
pub async fn confirm_contract(
    client: &DaemonClient,
    session_id: &str,
    contract_json: Option<&str>,
) -> Result<String, String> {
    let mut params = json!({ "session_id": session_id });
    if let Some(contract) = contract_json {
        params["contract"] = serde_json::from_str(contract)
            .map_err(|e| format!("invalid contract JSON: {e}"))?;
    }
    client
        .call("coder.confirm_contract", params)
        .await
        .and_then(to_string)
}

/// `coder.list` — all sessions (live + persisted), newest first.
pub async fn list(client: &DaemonClient) -> Result<String, String> {
    client.call("coder.list", json!({})).await.and_then(to_string)
}

/// `coder.get` — full session detail.
pub async fn get(client: &DaemonClient, session_id: &str) -> Result<String, String> {
    client
        .call("coder.get", json!({ "session_id": session_id }))
        .await
        .and_then(to_string)
}

/// `coder.respond` — answer a `user_input_requested` event (reserved).
pub async fn respond(
    client: &DaemonClient,
    session_id: &str,
    text: &str,
) -> Result<String, String> {
    client
        .call("coder.respond", json!({ "session_id": session_id, "text": text }))
        .await
        .and_then(to_string)
}

/// `coder.approve_merge` — approve publishes the `car/coder/<id>` branch;
/// deny abandons the session.
pub async fn approve_merge(
    client: &DaemonClient,
    session_id: &str,
    approve: bool,
) -> Result<String, String> {
    client
        .call(
            "coder.approve_merge",
            json!({ "session_id": session_id, "approve": approve }),
        )
        .await
        .and_then(to_string)
}

/// `coder.cancel` — stop the loop, abandon the session, clean the worktree.
pub async fn cancel(client: &DaemonClient, session_id: &str) -> Result<String, String> {
    client
        .call("coder.cancel", json!({ "session_id": session_id }))
        .await
        .and_then(to_string)
}