Skip to main content

sparrow/
console.rs

1use secrecy::ExposeSecret;
2use std::collections::HashMap;
3use std::net::SocketAddr;
4use std::process::Stdio;
5use std::sync::{Arc, RwLock};
6use std::time::Duration;
7use tokio::sync::{Mutex, broadcast, mpsc, oneshot};
8
9use crate::agent::AgentStore;
10use crate::auth::{AuthStore, Credential};
11use crate::capabilities::SkillLibrary;
12use crate::config::{Config, ConfigStore, FsConfigStore, ProviderConfig};
13use crate::engine::{ApprovalHandler, ApprovalRequest};
14use crate::event::{Decision, Event};
15use crate::memory::{Memory, MemoryDocKind};
16use crate::plan::ReadOnlyPlan;
17
18// ─── Embedded HTML ─────────────────────────────────────────────────────────────
19//
20// console.html is `include_str!`d into the binary so a release build ships as
21// a single file. The drawback: any edit to console.html requires a fresh
22// `cargo build` — a plain WebView reload (Ctrl+R) re-fetches the same baked-in
23// bytes. To make the WebView dev loop tight, set the env var
24// `SPARROW_CONSOLE_HTML` to a path on disk and that file is served instead.
25
26const CONSOLE_HTML_EMBEDDED: &str = include_str!("../console.html");
27
28fn console_html() -> std::borrow::Cow<'static, str> {
29    if let Ok(path) = std::env::var("SPARROW_CONSOLE_HTML") {
30        if !path.trim().is_empty() {
31            match std::fs::read_to_string(&path) {
32                Ok(contents) => return std::borrow::Cow::Owned(contents),
33                Err(e) => {
34                    tracing::warn!(
35                        "SPARROW_CONSOLE_HTML={} unreadable ({}); falling back to embedded HTML",
36                        path,
37                        e
38                    );
39                }
40            }
41        }
42    }
43    std::borrow::Cow::Borrowed(CONSOLE_HTML_EMBEDDED)
44}
45
46fn looks_like_api_key(value: &str) -> bool {
47    let value = value.trim();
48    value.starts_with("sk-")
49        || value.starts_with("nvapi-")
50        || value.starts_with("gsk_")
51        || value.starts_with("sk-or-")
52        || value.len() > 40 && !value.chars().all(|c| c.is_ascii_uppercase() || c == '_')
53}
54
55// ─── WebView server ────────────────────────────────────────────────────────────
56
57pub struct WebViewServer {
58    addr: SocketAddr,
59    event_tx: broadcast::Sender<Event>,
60    command_tx: Option<mpsc::UnboundedSender<String>>,
61    config: Option<Arc<RwLock<Config>>>,
62    approvals: Option<Arc<WebApprovalBroker>>,
63    skills: Option<Arc<dyn SkillLibrary>>,
64    memory: Option<Arc<dyn Memory>>,
65    agent_store: Option<Arc<dyn AgentStore>>,
66}
67
68impl WebViewServer {
69    #[allow(clippy::too_many_arguments)]
70    pub fn new(
71        addr: SocketAddr,
72        event_tx: broadcast::Sender<Event>,
73        command_tx: Option<mpsc::UnboundedSender<String>>,
74        config: Option<Arc<RwLock<Config>>>,
75        approvals: Option<Arc<WebApprovalBroker>>,
76        skills: Option<Arc<dyn SkillLibrary>>,
77        memory: Option<Arc<dyn Memory>>,
78        agent_store: Option<Arc<dyn AgentStore>>,
79    ) -> Self {
80        Self {
81            addr,
82            event_tx,
83            command_tx,
84            config,
85            approvals,
86            skills,
87            memory,
88            agent_store,
89        }
90    }
91
92    pub async fn serve(&self) -> anyhow::Result<()> {
93        use axum::{
94            Router,
95            extract::{State, ws::WebSocketUpgrade},
96            response::Html,
97            routing::{get, post},
98        };
99
100        let event_tx = self.event_tx.clone();
101
102        // Replay-on-connect buffer: a client that opens (or refreshes) the
103        // cockpit mid-run must see the run so far, not a blank feed. Keep the
104        // current run's public events in a bounded ring (cleared on each
105        // RunStarted) and send it to every new WebSocket before going live.
106        let recent: Arc<parking_lot::Mutex<std::collections::VecDeque<Event>>> =
107            Arc::new(parking_lot::Mutex::new(std::collections::VecDeque::new()));
108        {
109            let recent = recent.clone();
110            let mut brx = event_tx.subscribe();
111            tokio::spawn(async move {
112                const RING_CAP: usize = 800;
113                loop {
114                    match brx.recv().await {
115                        Ok(ev) => {
116                            if !ev.is_public() {
117                                continue;
118                            }
119                            let mut ring = recent.lock();
120                            if matches!(ev, Event::RunStarted { .. }) {
121                                ring.clear();
122                            }
123                            if ring.len() >= RING_CAP {
124                                ring.pop_front();
125                            }
126                            ring.push_back(ev);
127                        }
128                        // Lagged: skip dropped events; Closed: stop.
129                        Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
130                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
131                    }
132                }
133            });
134        }
135
136        let state = Arc::new(AppState {
137            event_tx: event_tx.clone(),
138            command_tx: self.command_tx.clone(),
139            config: self.config.clone(),
140            approvals: self.approvals.clone(),
141            skills: self.skills.clone(),
142            memory: self.memory.clone(),
143            agent_store: self.agent_store.clone(),
144        });
145
146        let app = Router::new()
147            // Reads from disk if `SPARROW_CONSOLE_HTML` is set (live-reload
148            // friendly: Ctrl+R picks up edits without recompile). Otherwise
149            // serves the include_str!()'d copy baked at compile time.
150            .route("/", get(|| async { Html(console_html().into_owned()) }))
151            // /healthz: lightweight liveness probe used by the VS Code
152            // extension and any external orchestrator to know the cockpit is
153            // up before opening a webview pointing at it.
154            .route(
155                "/healthz",
156                get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
157            )
158            .route("/run", post(run_task))
159            .route("/plan", post(plan_task))
160            .route("/cli", post(run_cli_command))
161            .route("/commands", get(get_commands))
162            .route("/memory", get(get_memory))
163            .route("/plugins", get(get_plugins))
164            .route("/tools", get(get_tools))
165            .route("/models", get(list_models))
166            .route("/status", get(get_status))
167            .route("/file", get(read_file))
168            .route("/conversation/reset", post(reset_conversation))
169            .route("/stop", post(stop_run))
170            .route("/approval", post(resolve_approval))
171            .route("/config", get(get_config).post(save_provider))
172            .route("/permissions", get(get_permissions).post(save_permissions))
173            .route("/security", get(get_security))
174            .route("/sessions", get(list_sessions))
175            .route("/sessions/load", post(load_session))
176            .route("/history", get(get_history))
177            .route("/agents", get(list_agents).post(create_agent))
178            .route("/agents/:name", axum::routing::delete(delete_agent))
179            .route("/skills", get(list_skills))
180            .route("/upload", post(upload_attachment))
181            .route("/artifacts", get(list_artifacts))
182            .route("/providers/scan", post(scan_provider_models))
183            .route("/routing", get(get_routing).post(save_routing))
184            .route("/update/check", get(check_update_api))
185            .route(
186                "/ws",
187                get(
188                    move |ws: WebSocketUpgrade, State(state): State<Arc<AppState>>| {
189                        let recent = recent.clone();
190                        async move {
191                            // Subscribe BEFORE snapshotting so no event falls in
192                            // the gap (a microsecond overlap may duplicate one
193                            // trailing event — harmless for a log feed).
194                            let rx = state.event_tx.subscribe();
195                            let snapshot: Vec<Event> = recent.lock().iter().cloned().collect();
196                            ws.on_upgrade(move |socket| handle_ws(socket, rx, snapshot))
197                        }
198                    },
199                ),
200            )
201            .with_state(state);
202
203        let listener = tokio::net::TcpListener::bind(self.addr).await?;
204        tracing::info!("WebView console: http://{}", self.addr);
205
206        axum::serve(listener, app).await?;
207        Ok(())
208    }
209}
210
211#[derive(Clone)]
212struct AppState {
213    event_tx: broadcast::Sender<Event>,
214    command_tx: Option<mpsc::UnboundedSender<String>>,
215    config: Option<Arc<RwLock<Config>>>,
216    approvals: Option<Arc<WebApprovalBroker>>,
217    skills: Option<Arc<dyn SkillLibrary>>,
218    memory: Option<Arc<dyn Memory>>,
219    agent_store: Option<Arc<dyn AgentStore>>,
220}
221
222#[derive(Default)]
223pub struct WebApprovalBroker {
224    pending: Mutex<HashMap<String, oneshot::Sender<Decision>>>,
225}
226
227impl WebApprovalBroker {
228    pub fn new() -> Self {
229        Self::default()
230    }
231
232    pub async fn resolve(&self, id: &str, decision: Decision) -> bool {
233        let mut pending = self.pending.lock().await;
234        pending
235            .remove(id)
236            .map(|tx| tx.send(decision).is_ok())
237            .unwrap_or(false)
238    }
239}
240
241#[async_trait::async_trait]
242impl ApprovalHandler for WebApprovalBroker {
243    async fn request_approval(&self, request: ApprovalRequest) -> Decision {
244        let (tx, rx) = oneshot::channel();
245        {
246            let mut pending = self.pending.lock().await;
247            pending.insert(request.id, tx);
248        }
249        rx.await.unwrap_or(Decision::Deny)
250    }
251}
252
253#[derive(serde::Deserialize)]
254struct RunRequest {
255    task: String,
256    #[serde(default)]
257    model_override: Option<String>,
258    #[serde(default)]
259    agent_name: Option<String>,
260}
261
262#[derive(serde::Serialize)]
263struct RunResponse {
264    ok: bool,
265    message: String,
266}
267
268#[derive(serde::Serialize)]
269struct PlanResponse {
270    ok: bool,
271    message: String,
272    plan: Option<ReadOnlyPlan>,
273}
274
275#[derive(serde::Serialize)]
276struct CommandView {
277    name: String,
278    description: String,
279    usage: String,
280    source: String,
281}
282
283#[derive(serde::Serialize)]
284struct CommandsResponse {
285    ok: bool,
286    message: String,
287    commands: Vec<CommandView>,
288}
289
290#[derive(serde::Deserialize)]
291struct CliCommandRequest {
292    command: String,
293}
294
295#[derive(serde::Serialize)]
296struct CliCommandResponse {
297    ok: bool,
298    message: String,
299    status: Option<i32>,
300    stdout: String,
301    stderr: String,
302}
303
304#[derive(serde::Deserialize)]
305struct ApprovalResponseRequest {
306    id: String,
307    decision: String,
308}
309
310#[derive(serde::Serialize)]
311struct ProviderView {
312    name: String,
313    label: String,
314    adapter: String,
315    base_url: Option<String>,
316    models: Vec<String>,
317    tags: Vec<String>,
318    notes: String,
319    api_key_env: Option<String>,
320    has_credential: bool,
321    configured: bool,
322}
323
324#[derive(serde::Serialize)]
325struct BudgetView {
326    session_usd: f64,
327    daily_usd: f64,
328}
329
330#[derive(serde::Serialize)]
331struct ConfigResponse {
332    ok: bool,
333    message: String,
334    autonomy: String,
335    sandbox: String,
336    providers: Vec<ProviderView>,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    budget: Option<BudgetView>,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    workdir: Option<String>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    skills_count: Option<usize>,
343}
344
345#[derive(serde::Serialize)]
346struct PermissionsResponse {
347    ok: bool,
348    message: String,
349    permissions: Option<crate::permissions::PermissionConfig>,
350    /// Per-tool persisted decisions (for the WebView approval UI).
351    #[serde(default)]
352    persisted_tools: std::collections::HashMap<String, String>,
353}
354
355#[derive(serde::Deserialize)]
356struct PermissionsRequest {
357    mode: Option<String>,
358    /// Per-tool permission updates: {"web_search": "allow_always", "code_exec": "deny"}
359    tools: Option<std::collections::HashMap<String, String>>,
360}
361
362#[derive(serde::Serialize)]
363struct MemoryDocView {
364    kind: String,
365    chars: usize,
366    limit: usize,
367    updated_at: String,
368    content: String,
369}
370
371#[derive(serde::Serialize)]
372struct MemoryFactView {
373    id: String,
374    key: String,
375    value: String,
376    updated_at: String,
377}
378
379#[derive(serde::Serialize)]
380struct MemoryResponse {
381    ok: bool,
382    message: String,
383    stats: Option<crate::memory::MemoryStats>,
384    docs: Vec<MemoryDocView>,
385    facts: Vec<MemoryFactView>,
386}
387
388#[derive(serde::Serialize)]
389struct PluginView {
390    name: String,
391    version: String,
392    description: String,
393    commands: usize,
394    skills: usize,
395    hooks: usize,
396    allowed: bool,
397    warnings: Vec<String>,
398}
399
400#[derive(serde::Serialize)]
401struct PluginsResponse {
402    ok: bool,
403    message: String,
404    plugins: Vec<PluginView>,
405}
406
407#[derive(serde::Serialize)]
408struct ToolsResponse {
409    ok: bool,
410    message: String,
411    toolsets: Vec<String>,
412    tools: Vec<crate::tools::ToolMetadata>,
413}
414
415#[derive(serde::Deserialize)]
416struct HistoryQuery {
417    limit: Option<usize>,
418}
419
420#[derive(serde::Serialize)]
421struct HistoryResponse {
422    ok: bool,
423    message: String,
424    inputs: Vec<String>,
425}
426
427#[derive(serde::Deserialize)]
428struct ProviderRequest {
429    #[serde(default)]
430    name: String,
431    #[serde(default)]
432    adapter: String,
433    base_url: Option<String>,
434    #[serde(default)]
435    models: Vec<String>,
436    api_key_env: Option<String>,
437    api_key: Option<String>,
438    autonomy: Option<String>,
439    sandbox: Option<String>,
440}
441
442async fn run_task(
443    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
444    axum::extract::Json(req): axum::extract::Json<RunRequest>,
445) -> axum::extract::Json<RunResponse> {
446    let task = req.task.trim().to_string();
447    if task.is_empty() {
448        return axum::extract::Json(RunResponse {
449            ok: false,
450            message: "empty task".into(),
451        });
452    }
453
454    // Prepend model override hint so the engine can parse it.
455    // The frontend sends "provider:model" — strip the provider prefix to
456    // match brain.id() which returns just the model name.
457    let dispatch = if let Some(m) = req.model_override.filter(|s| !s.is_empty()) {
458        let model_only = m.rsplit(':').next().unwrap_or(&m);
459        format!("__model:{model_only}__ {task}")
460    } else {
461        task
462    };
463    // Prepend agent identity override. When an agent is selected, load its
464    // soul and embed the identity so the engine runs with that persona.
465    let dispatch = if let Some(ref agent_name) = req.agent_name.filter(|s| !s.is_empty()) {
466        if let Some(ref store) = state.agent_store {
467            if let Some(soul) = store.get(agent_name) {
468                let identity = soul.to_identity();
469                // Base64-encode the personality to avoid delimiter collisions
470                use base64::{Engine as _, engine::general_purpose::STANDARD};
471                let b64 = STANDARD.encode(identity.personality.as_bytes());
472                format!(
473                    "__agent:{}__{}__{}__ {}",
474                    identity.name, identity.role, b64, dispatch
475                )
476            } else {
477                dispatch
478            }
479        } else {
480            dispatch
481        }
482    } else {
483        dispatch
484    };
485    match &state.command_tx {
486        Some(tx) if tx.send(dispatch).is_ok() => axum::extract::Json(RunResponse {
487            ok: true,
488            message: "queued".into(),
489        }),
490        _ => axum::extract::Json(RunResponse {
491            ok: false,
492            message: "console command channel unavailable".into(),
493        }),
494    }
495}
496
497async fn plan_task(
498    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
499    axum::extract::Json(req): axum::extract::Json<RunRequest>,
500) -> axum::extract::Json<PlanResponse> {
501    let task = req.task.trim().to_string();
502    if task.is_empty() {
503        return axum::extract::Json(PlanResponse {
504            ok: false,
505            message: "empty task".into(),
506            plan: None,
507        });
508    }
509    let commands = commands_for_state(&state);
510    let plan = crate::plan::build_read_only_plan(&task, &commands);
511    axum::extract::Json(PlanResponse {
512        ok: true,
513        message: "planned".into(),
514        plan: Some(plan),
515    })
516}
517
518async fn run_cli_command(
519    axum::extract::Json(req): axum::extract::Json<CliCommandRequest>,
520) -> axum::extract::Json<CliCommandResponse> {
521    let args = match webview_cli_args(&req.command) {
522        Ok(args) => args,
523        Err(message) => {
524            return axum::extract::Json(CliCommandResponse {
525                ok: false,
526                message,
527                status: None,
528                stdout: String::new(),
529                stderr: String::new(),
530            });
531        }
532    };
533
534    if let Some(message) = blocked_webview_cli_command(&args) {
535        return axum::extract::Json(CliCommandResponse {
536            ok: false,
537            message,
538            status: None,
539            stdout: String::new(),
540            stderr: String::new(),
541        });
542    }
543
544    let exe = match std::env::current_exe() {
545        Ok(exe) => exe,
546        Err(e) => {
547            return axum::extract::Json(CliCommandResponse {
548                ok: false,
549                message: format!("cannot locate Sparrow executable: {e}"),
550                status: None,
551                stdout: String::new(),
552                stderr: String::new(),
553            });
554        }
555    };
556
557    let child = match tokio::process::Command::new(exe)
558        .args(&args)
559        .env("SPARROW_WEBVIEW_CLI", "1")
560        .stdin(Stdio::null())
561        .stdout(Stdio::piped())
562        .stderr(Stdio::piped())
563        .kill_on_drop(true)
564        .spawn()
565    {
566        Ok(child) => child,
567        Err(e) => {
568            return axum::extract::Json(CliCommandResponse {
569                ok: false,
570                message: format!("failed to launch Sparrow command: {e}"),
571                status: None,
572                stdout: String::new(),
573                stderr: String::new(),
574            });
575        }
576    };
577
578    let output = match tokio::time::timeout(Duration::from_secs(45), child.wait_with_output()).await
579    {
580        Ok(Ok(output)) => output,
581        Ok(Err(e)) => {
582            return axum::extract::Json(CliCommandResponse {
583                ok: false,
584                message: format!("Sparrow command failed to finish: {e}"),
585                status: None,
586                stdout: String::new(),
587                stderr: String::new(),
588            });
589        }
590        Err(_) => {
591            return axum::extract::Json(CliCommandResponse {
592                ok: false,
593                message: "Sparrow command timed out after 45s".into(),
594                status: None,
595                stdout: String::new(),
596                stderr: String::new(),
597            });
598        }
599    };
600
601    let status = output.status.code();
602    let stdout = String::from_utf8_lossy(&output.stdout)
603        .trim_end()
604        .to_string();
605    let stderr = String::from_utf8_lossy(&output.stderr)
606        .trim_end()
607        .to_string();
608    axum::extract::Json(CliCommandResponse {
609        ok: output.status.success(),
610        message: if output.status.success() {
611            "command completed".into()
612        } else {
613            format!("command exited with {}", status.unwrap_or(-1))
614        },
615        status,
616        stdout,
617        stderr,
618    })
619}
620
621fn webview_cli_args(command: &str) -> Result<Vec<String>, String> {
622    let command = command.trim().trim_start_matches('/').trim();
623    if command.is_empty() {
624        return Err("empty command".into());
625    }
626    let mut args = split_webview_command(command)?;
627    if args.is_empty() {
628        return Err("empty command".into());
629    }
630    match args[0].as_str() {
631        "models" => args[0] = "model".into(),
632        "routing" => args[0] = "route".into(),
633        _ => {}
634    }
635    if args[0] == "model" && args.len() == 1 {
636        args.push("--list".into());
637    }
638    if args[0] == "run" && args.len() > 2 {
639        let task = args[1..].join(" ");
640        args.truncate(1);
641        args.push(task);
642    }
643    if args[0] == "plan" && args.len() > 2 {
644        let task = args[1..].join(" ");
645        args.truncate(1);
646        args.push(task);
647    }
648    if args[0] == "swarm" && args.len() > 2 {
649        let task = args[1..].join(" ");
650        args.truncate(1);
651        args.push(task);
652    }
653    Ok(args)
654}
655
656fn blocked_webview_cli_command(args: &[String]) -> Option<String> {
657    let first = args.first().map(String::as_str)?;
658    if matches!(first, "console" | "tui" | "chat" | "daemon") {
659        return Some(format!(
660            "`/{first}` opens an interactive process; launch it from a terminal instead."
661        ));
662    }
663    if first == "gateway" && args.get(1).map(String::as_str) == Some("start") {
664        return Some("`/gateway start` starts a daemon; launch it from a terminal instead.".into());
665    }
666    None
667}
668
669fn split_webview_command(input: &str) -> Result<Vec<String>, String> {
670    let mut args = Vec::new();
671    let mut current = String::new();
672    let mut chars = input.chars().peekable();
673    let mut quote: Option<char> = None;
674    while let Some(ch) = chars.next() {
675        match (quote, ch) {
676            (Some(q), c) if c == q => quote = None,
677            (Some(_), '\\') => {
678                if let Some(next) = chars.next() {
679                    current.push(next);
680                }
681            }
682            (Some(_), c) => current.push(c),
683            (None, '\'' | '"') => quote = Some(ch),
684            (None, c) if c.is_whitespace() => {
685                if !current.is_empty() {
686                    args.push(std::mem::take(&mut current));
687                }
688            }
689            (None, c) => current.push(c),
690        }
691    }
692    if let Some(q) = quote {
693        return Err(format!("unterminated {q} quote"));
694    }
695    if !current.is_empty() {
696        args.push(current);
697    }
698    Ok(args)
699}
700
701async fn get_commands(
702    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
703) -> axum::extract::Json<CommandsResponse> {
704    let commands = commands_for_state(&state)
705        .into_iter()
706        .map(|cmd| CommandView {
707            name: format!("/{}", cmd.name),
708            description: cmd.description,
709            usage: cmd.body,
710            source: match cmd.source {
711                crate::commands::SlashCommandSource::Builtin => "builtin".into(),
712                crate::commands::SlashCommandSource::Project(path) => {
713                    format!("project:{}", path.display())
714                }
715                crate::commands::SlashCommandSource::User(path) => {
716                    format!("user:{}", path.display())
717                }
718                crate::commands::SlashCommandSource::Skill(name) => format!("skill:{}", name),
719                crate::commands::SlashCommandSource::Plugin(name) => format!("plugin:{}", name),
720            },
721        })
722        .collect();
723    axum::extract::Json(CommandsResponse {
724        ok: true,
725        message: "commands loaded".into(),
726        commands,
727    })
728}
729
730fn commands_for_state(state: &AppState) -> Vec<crate::commands::SlashCommand> {
731    let project_root = std::env::current_dir().unwrap_or_default();
732    let config_dir = state
733        .config
734        .as_ref()
735        .and_then(|cfg| cfg.read().ok().map(|cfg| cfg.config_dir.clone()))
736        .unwrap_or_else(|| {
737            dirs::config_dir()
738                .unwrap_or_else(|| std::path::PathBuf::from("."))
739                .join("sparrow")
740        });
741    crate::commands::all_commands(&project_root, &config_dir, state.skills.as_deref())
742}
743
744async fn get_memory(
745    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
746) -> axum::extract::Json<MemoryResponse> {
747    let Some(memory) = &state.memory else {
748        return axum::extract::Json(MemoryResponse {
749            ok: false,
750            message: "memory unavailable".into(),
751            stats: None,
752            docs: Vec::new(),
753            facts: Vec::new(),
754        });
755    };
756    let stats = memory.memory_stats();
757    let docs = [MemoryDocKind::Memory, MemoryDocKind::User]
758        .into_iter()
759        .filter_map(|kind| {
760            memory.memory_doc(kind).map(|doc| MemoryDocView {
761                kind: kind.as_str().to_string(),
762                chars: doc.content.chars().count(),
763                limit: kind.limit(),
764                updated_at: doc.updated_at,
765                content: doc.content,
766            })
767        })
768        .collect();
769    let facts = memory
770        .all_facts()
771        .into_iter()
772        .take(25)
773        .map(|fact| MemoryFactView {
774            id: fact.id,
775            key: fact.key,
776            value: fact.value,
777            updated_at: fact.updated_at,
778        })
779        .collect();
780    axum::extract::Json(MemoryResponse {
781        ok: true,
782        message: "loaded".into(),
783        stats: Some(stats),
784        docs,
785        facts,
786    })
787}
788
789async fn get_plugins(
790    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
791) -> axum::extract::Json<PluginsResponse> {
792    let config_dir = state
793        .config
794        .as_ref()
795        .and_then(|cfg| cfg.read().ok().map(|cfg| cfg.config_dir.clone()))
796        .unwrap_or_else(|| {
797            dirs::config_dir()
798                .unwrap_or_else(|| std::path::PathBuf::from("."))
799                .join("sparrow")
800        });
801    let dirs = [
802        std::env::current_dir()
803            .unwrap_or_default()
804            .join(".sparrow")
805            .join("plugins"),
806        config_dir.join("plugins"),
807    ];
808    let mut plugins = Vec::new();
809    for dir in dirs {
810        let registry = crate::capabilities::plugin::PluginRegistry::new(dir);
811        for plugin in registry.scan() {
812            let audit = registry.audit(&plugin);
813            plugins.push(PluginView {
814                name: plugin.manifest.name,
815                version: plugin.manifest.version,
816                description: plugin.manifest.description,
817                commands: plugin.manifest.commands.len(),
818                skills: plugin.manifest.skills.len(),
819                hooks: plugin.manifest.hooks.len(),
820                allowed: audit.allowed,
821                warnings: audit.warnings,
822            });
823        }
824    }
825    axum::extract::Json(PluginsResponse {
826        ok: true,
827        message: "loaded".into(),
828        plugins,
829    })
830}
831
832async fn get_tools() -> axum::extract::Json<ToolsResponse> {
833    axum::extract::Json(ToolsResponse {
834        ok: true,
835        message: "loaded".into(),
836        toolsets: crate::tools::TOOLSETS
837            .iter()
838            .map(|set| set.to_string())
839            .collect(),
840        tools: crate::tools::known_tool_metadata(None),
841    })
842}
843
844async fn list_models(
845    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
846) -> axum::extract::Json<serde_json::Value> {
847    use crate::config::providers::provider_registry;
848    let providers = provider_registry();
849    let out: Vec<serde_json::Value> = providers
850        .iter()
851        .map(|p| {
852            // Curated models from the static registry.
853            let mut models: Vec<serde_json::Value> = p
854                .models
855                .iter()
856                .map(|m| {
857                    serde_json::json!({
858                        "name": m.name,
859                        "label": m.label,
860                        "tags": m.tags,
861                        "context_window": m.context_window,
862                        "cost_in": m.cost_input_per_mtok,
863                        "cost_out": m.cost_output_per_mtok,
864                        "recommended": m.recommended,
865                        "source": "registry",
866                    })
867                })
868                .collect();
869            // Merge live-discovered models from the SQLite cache (e.g. the 92
870            // NVIDIA models) so the picker shows everything, not just curated.
871            // For each discovered model, infer per-model caps from the name so
872            // the WebView context-window meter adapts (DeepSeek V4 Pro = 1M,
873            // not the previous hard-coded 128k default).
874            if let Some(mem) = &state.memory {
875                let curated: std::collections::HashSet<String> =
876                    p.models.iter().map(|m| m.name.clone()).collect();
877                for name in mem.get_discovered_models(&p.id) {
878                    if !curated.contains(&name) {
879                        let caps = crate::config::providers::model_caps(&p.id, &name);
880                        models.push(serde_json::json!({
881                            "name": name,
882                            "label": name,
883                            "tags": [],
884                            "context_window": caps.context_window,
885                            "max_output": caps.max_output,
886                            "cost_in": caps.cost_input_per_mtok,
887                            "cost_out": caps.cost_output_per_mtok,
888                            "recommended": false,
889                            "source": "discovered",
890                        }));
891                    }
892                }
893            }
894            serde_json::json!({
895                "id": p.id,
896                "label": p.label,
897                "tags": p.tags,
898                "model_count": models.len(),
899                "models": models,
900            })
901        })
902        .collect();
903    axum::extract::Json(serde_json::json!({ "ok": true, "providers": out }))
904}
905
906async fn stop_run(
907    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
908) -> axum::extract::Json<RunResponse> {
909    match &state.command_tx {
910        Some(tx) if tx.send("__stop__".to_string()).is_ok() => axum::extract::Json(RunResponse {
911            ok: true,
912            message: "stop requested".into(),
913        }),
914        _ => axum::extract::Json(RunResponse {
915            ok: false,
916            message: "console command channel unavailable".into(),
917        }),
918    }
919}
920
921async fn reset_conversation(
922    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
923) -> axum::extract::Json<RunResponse> {
924    // Send a sentinel string through the command channel; main.rs interprets
925    // __reset_conversation__ as a request to drain conv_history.
926    match &state.command_tx {
927        Some(tx) if tx.send("__reset_conversation__".to_string()).is_ok() => {
928            axum::extract::Json(RunResponse {
929                ok: true,
930                message: "conversation cleared".into(),
931            })
932        }
933        _ => axum::extract::Json(RunResponse {
934            ok: false,
935            message: "console command channel unavailable".into(),
936        }),
937    }
938}
939
940async fn get_status() -> axum::extract::Json<serde_json::Value> {
941    use crate::config::providers::provider_registry;
942    let providers = provider_registry();
943    axum::extract::Json(serde_json::json!({
944        "ok": true,
945        "version": env!("CARGO_PKG_VERSION"),
946        "providers_total": providers.len(),
947        "workdir": std::env::current_dir().ok().map(|p| p.to_string_lossy().to_string()),
948    }))
949}
950
951/// Check for Sparrow updates — called by the WebView frontend on load.
952async fn check_update_api() -> axum::extract::Json<serde_json::Value> {
953    let current = env!("CARGO_PKG_VERSION");
954    match crate::update::check_update() {
955        Some(info) => axum::extract::Json(serde_json::json!({
956            "update_available": true,
957            "current": info.current,
958            "latest": info.latest,
959            "download_url": info.download_url,
960            "crate_url": info.crate_url,
961            "release_url": info.release_url,
962            "install_cmd": info.install_cmd,
963        })),
964        None => axum::extract::Json(serde_json::json!({
965            "update_available": false,
966            "current": current,
967            "latest": current,
968        })),
969    }
970}
971
972#[derive(serde::Deserialize)]
973struct FileQuery {
974    path: String,
975}
976
977async fn read_file(
978    axum::extract::Query(q): axum::extract::Query<FileQuery>,
979) -> axum::response::Response {
980    use axum::response::IntoResponse;
981    // Sandbox: only allow paths inside cwd.
982    let cwd = match std::env::current_dir() {
983        Ok(d) => d,
984        Err(_) => {
985            return (
986                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
987                "cwd unavailable",
988            )
989                .into_response();
990        }
991    };
992    // Canonicalize cwd too so UNC prefixes match on Windows.
993    let cwd_canon = cwd.canonicalize().unwrap_or(cwd.clone());
994    let requested = std::path::Path::new(&q.path);
995    let canonical = match cwd.join(requested).canonicalize() {
996        Ok(p) => p,
997        Err(_) => return (axum::http::StatusCode::NOT_FOUND, "file not found").into_response(),
998    };
999    if !canonical.starts_with(&cwd_canon) {
1000        return (axum::http::StatusCode::FORBIDDEN, "path outside workdir").into_response();
1001    }
1002    match std::fs::read_to_string(&canonical) {
1003        Ok(content) => {
1004            let ext = canonical.extension().and_then(|e| e.to_str()).unwrap_or("");
1005            let lang = match ext {
1006                "rs" => "rust",
1007                "js" | "ts" | "jsx" | "tsx" => "javascript",
1008                "py" => "python",
1009                "toml" => "toml",
1010                "md" => "markdown",
1011                "html" => "html",
1012                "css" => "css",
1013                "json" => "json",
1014                _ => "text",
1015            };
1016            axum::extract::Json(serde_json::json!({
1017                "ok": true, "path": q.path, "lang": lang,
1018                "lines": content.lines().count(),
1019                "content": content,
1020            }))
1021            .into_response()
1022        }
1023        Err(_) => (axum::http::StatusCode::NOT_FOUND, "cannot read file").into_response(),
1024    }
1025}
1026
1027async fn resolve_approval(
1028    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1029    axum::extract::Json(req): axum::extract::Json<ApprovalResponseRequest>,
1030) -> axum::extract::Json<RunResponse> {
1031    let Some(approvals) = &state.approvals else {
1032        return axum::extract::Json(RunResponse {
1033            ok: false,
1034            message: "approval channel unavailable".into(),
1035        });
1036    };
1037    let decision = match req.decision.trim().to_lowercase().as_str() {
1038        // The scope (once/session/always) is enforced client-side: the frontend
1039        // remembers session-approved tool names and skips the next prompt; an
1040        // "always" decision should be paired with a separate POST /permissions
1041        // call to persist the rule. All three map to Allow at the engine level.
1042        "allow" | "approve" | "approved" | "allow_once" | "allow_session" | "allow_always" => {
1043            Decision::Allow
1044        }
1045        "deny" | "reject" | "rejected" => Decision::Deny,
1046        _ => {
1047            return axum::extract::Json(RunResponse {
1048                ok: false,
1049                message: "decision must be approve/allow_once/allow_session/allow_always/deny"
1050                    .into(),
1051            });
1052        }
1053    };
1054    if approvals.resolve(req.id.trim(), decision).await {
1055        axum::extract::Json(RunResponse {
1056            ok: true,
1057            message: "approval resolved".into(),
1058        })
1059    } else {
1060        axum::extract::Json(RunResponse {
1061            ok: false,
1062            message: "approval not found or already resolved".into(),
1063        })
1064    }
1065}
1066
1067async fn get_config(
1068    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1069) -> axum::extract::Json<ConfigResponse> {
1070    let Some(shared) = &state.config else {
1071        return axum::extract::Json(ConfigResponse {
1072            ok: false,
1073            message: "config unavailable".into(),
1074            budget: None,
1075            workdir: None,
1076            skills_count: None,
1077            autonomy: String::new(),
1078            sandbox: String::new(),
1079            providers: vec![],
1080        });
1081    };
1082
1083    let cfg = shared.read().expect("config lock poisoned").clone();
1084    let auth = crate::auth::store::ChainedAuthStore::new(cfg.config_dir.clone());
1085    let mut providers = crate::config::providers::onboarding_providers()
1086        .into_iter()
1087        .map(|def| {
1088            let configured = cfg.providers.get(&def.id);
1089            let api_key_env = configured
1090                .and_then(|p| {
1091                    p.api_key_env
1092                        .as_ref()
1093                        .filter(|value| !looks_like_api_key(value))
1094                        .cloned()
1095                })
1096                .or_else(|| def.api_key_env.clone());
1097            let has_credential = auth.get(&def.id).is_some()
1098                || configured
1099                    .and_then(|p| p.api_key_env.as_ref())
1100                    .map(|value| {
1101                        looks_like_api_key(value)
1102                            || std::env::var(value)
1103                                .map(|env_value| !env_value.is_empty())
1104                                .unwrap_or(false)
1105                    })
1106                    .unwrap_or(false)
1107                || api_key_env
1108                    .as_ref()
1109                    .map(|value| {
1110                        std::env::var(value)
1111                            .map(|env_value| !env_value.is_empty())
1112                            .unwrap_or(false)
1113                    })
1114                    .unwrap_or(false);
1115
1116            // Merge configured + curated + discovered into a single sorted
1117            // unique list so the config panel's "X models" count survives
1118            // across sessions (was only counting the static registry, hiding
1119            // the 60+ models the user had just scanned).
1120            let mut models: Vec<String> = configured
1121                .map(|p| {
1122                    if p.models.is_empty() {
1123                        def.models.iter().map(|m| m.name.clone()).collect()
1124                    } else {
1125                        p.models.clone()
1126                    }
1127                })
1128                .unwrap_or_else(|| def.models.iter().map(|m| m.name.clone()).collect());
1129            if let Some(mem) = &state.memory {
1130                let known: std::collections::HashSet<String> = models.iter().cloned().collect();
1131                for name in mem.get_discovered_models(&def.id) {
1132                    if !known.contains(&name) {
1133                        models.push(name);
1134                    }
1135                }
1136            }
1137            ProviderView {
1138                name: def.id,
1139                label: def.label,
1140                adapter: configured.map(|p| p.adapter.clone()).unwrap_or(def.adapter),
1141                base_url: configured
1142                    .and_then(|p| p.base_url.clone())
1143                    .or(Some(def.base_url)),
1144                models,
1145                tags: def.tags,
1146                notes: def.notes,
1147                api_key_env,
1148                has_credential,
1149                configured: configured.is_some(),
1150            }
1151        })
1152        .collect::<Vec<_>>();
1153
1154    for (name, p) in &cfg.providers {
1155        if providers.iter().any(|view| &view.name == name) {
1156            continue;
1157        }
1158        let api_key_env = p
1159            .api_key_env
1160            .as_ref()
1161            .filter(|value| !looks_like_api_key(value))
1162            .cloned();
1163        providers.push(ProviderView {
1164            name: name.clone(),
1165            label: name.clone(),
1166            adapter: p.adapter.clone(),
1167            base_url: p.base_url.clone(),
1168            models: p.models.clone(),
1169            tags: vec!["custom".into()],
1170            notes: "Custom configured provider.".into(),
1171            api_key_env: api_key_env.clone(),
1172            has_credential: auth.get(name).is_some()
1173                || p.api_key_env
1174                    .as_ref()
1175                    .map(|value| {
1176                        looks_like_api_key(value)
1177                            || std::env::var(value)
1178                                .map(|env_value| !env_value.is_empty())
1179                                .unwrap_or(false)
1180                    })
1181                    .unwrap_or(false),
1182            configured: true,
1183        });
1184    }
1185    providers.sort_by(|a, b| a.name.cmp(&b.name));
1186
1187    axum::extract::Json(ConfigResponse {
1188        ok: true,
1189        message: "loaded".into(),
1190        autonomy: format!("{:?}", cfg.defaults.autonomy),
1191        sandbox: cfg.defaults.sandbox,
1192        providers,
1193        budget: Some(BudgetView {
1194            session_usd: cfg.budget.session_usd,
1195            daily_usd: cfg.budget.daily_usd,
1196        }),
1197        workdir: std::env::current_dir()
1198            .ok()
1199            .map(|p| p.to_string_lossy().to_string()),
1200        skills_count: None,
1201    })
1202}
1203
1204async fn save_provider(
1205    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1206    axum::extract::Json(req): axum::extract::Json<ProviderRequest>,
1207) -> axum::extract::Json<RunResponse> {
1208    let Some(shared) = &state.config else {
1209        return axum::extract::Json(RunResponse {
1210            ok: false,
1211            message: "config unavailable".into(),
1212        });
1213    };
1214
1215    let mut cfg = shared.write().expect("config lock poisoned");
1216    if let Some(level) = parse_autonomy(req.autonomy.as_deref()) {
1217        cfg.defaults.autonomy = level;
1218    }
1219    if let Some(sandbox) = req
1220        .sandbox
1221        .as_ref()
1222        .map(|s| s.trim().to_string())
1223        .filter(|s| !s.is_empty())
1224    {
1225        cfg.defaults.sandbox = sandbox;
1226    }
1227
1228    let name = req.name.trim().to_lowercase();
1229    if name.is_empty() {
1230        let saved = cfg.clone();
1231        let store = FsConfigStore::new(saved.config_dir.clone());
1232        if let Err(err) = store.save(&saved) {
1233            return axum::extract::Json(RunResponse {
1234                ok: false,
1235                message: format!("config save failed: {}", err),
1236            });
1237        }
1238        return axum::extract::Json(RunResponse {
1239            ok: true,
1240            message: "runtime preferences saved".into(),
1241        });
1242    }
1243
1244    let raw_api_key_env = req
1245        .api_key_env
1246        .as_ref()
1247        .map(|s| s.trim().to_string())
1248        .filter(|s| !s.is_empty());
1249    let api_key_env = raw_api_key_env
1250        .as_ref()
1251        .filter(|value| !looks_like_api_key(value))
1252        .cloned();
1253    let api_key_from_env_field = raw_api_key_env
1254        .as_ref()
1255        .filter(|value| looks_like_api_key(value))
1256        .cloned();
1257
1258    cfg.providers.insert(
1259        name.clone(),
1260        ProviderConfig {
1261            adapter: req.adapter.trim().to_string(),
1262            base_url: req
1263                .base_url
1264                .as_ref()
1265                .map(|s| s.trim().to_string())
1266                .filter(|s| !s.is_empty()),
1267            models: req
1268                .models
1269                .into_iter()
1270                .map(|m| m.trim().to_string())
1271                .filter(|m| !m.is_empty())
1272                .collect(),
1273            api_key_env,
1274        },
1275    );
1276
1277    let saved = cfg.clone();
1278    let store = FsConfigStore::new(saved.config_dir.clone());
1279    if let Err(err) = store.save(&saved) {
1280        return axum::extract::Json(RunResponse {
1281            ok: false,
1282            message: format!("config save failed: {}", err),
1283        });
1284    }
1285
1286    if let Some(key) = req
1287        .api_key
1288        .map(|k| k.trim().to_string())
1289        .filter(|k| !k.is_empty())
1290        .or(api_key_from_env_field)
1291    {
1292        let auth = crate::auth::store::ChainedAuthStore::new(saved.config_dir);
1293        if let Err(err) = auth.set(&name, Credential::api_key(key)) {
1294            return axum::extract::Json(RunResponse {
1295                ok: false,
1296                message: format!("credential save failed: {}", err),
1297            });
1298        }
1299    }
1300
1301    axum::extract::Json(RunResponse {
1302        ok: true,
1303        message: format!("provider '{}' saved", name),
1304    })
1305}
1306
1307/// Hard cap for WebView attachments (10 MB).
1308pub const MAX_ATTACHMENT_BYTES: usize = 10 * 1024 * 1024;
1309
1310/// Where attachments are stored, relative to the current working directory.
1311pub fn attachments_dir() -> std::path::PathBuf {
1312    std::env::current_dir()
1313        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1314        .join(".sparrow")
1315        .join("attachments")
1316}
1317
1318#[derive(serde::Serialize)]
1319pub struct AttachmentMetadata {
1320    pub name: String,
1321    pub path: String,
1322    pub size: u64,
1323    pub mime: String,
1324    pub kind: &'static str,
1325}
1326
1327pub fn classify_attachment(mime: &str, ext: &str) -> &'static str {
1328    let ext = ext.to_ascii_lowercase();
1329    if mime.starts_with("image/")
1330        || matches!(
1331            ext.as_str(),
1332            "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
1333        )
1334    {
1335        "image"
1336    } else if mime.starts_with("audio/")
1337        || matches!(ext.as_str(), "mp3" | "wav" | "m4a" | "ogg" | "flac")
1338    {
1339        "audio"
1340    } else if mime == "application/pdf" || ext == "pdf" {
1341        "pdf"
1342    } else if mime.starts_with("text/")
1343        || matches!(
1344            ext.as_str(),
1345            "md" | "txt" | "csv" | "json" | "toml" | "yml" | "yaml"
1346        )
1347    {
1348        "text"
1349    } else {
1350        "file"
1351    }
1352}
1353
1354async fn upload_attachment(
1355    mut multipart: axum::extract::Multipart,
1356) -> axum::extract::Json<serde_json::Value> {
1357    let dir = attachments_dir();
1358    if let Err(e) = std::fs::create_dir_all(&dir) {
1359        return axum::extract::Json(serde_json::json!({
1360            "ok": false,
1361            "message": format!("could not create attachments dir: {}", e),
1362        }));
1363    }
1364    let mut accepted: Vec<AttachmentMetadata> = Vec::new();
1365    let mut rejected: Vec<serde_json::Value> = Vec::new();
1366    while let Ok(Some(field)) = multipart.next_field().await {
1367        let original = field
1368            .file_name()
1369            .map(|s| s.to_string())
1370            .unwrap_or_else(|| "upload.bin".into());
1371        let content_type = field
1372            .content_type()
1373            .unwrap_or("application/octet-stream")
1374            .to_string();
1375        let data = match field.bytes().await {
1376            Ok(b) => b,
1377            Err(e) => {
1378                rejected.push(
1379                    serde_json::json!({"name": original, "reason": format!("read error: {}", e)}),
1380                );
1381                continue;
1382            }
1383        };
1384        if data.len() > MAX_ATTACHMENT_BYTES {
1385            rejected.push(serde_json::json!({
1386                "name": original,
1387                "reason": format!("too large: {} bytes > limit {}", data.len(), MAX_ATTACHMENT_BYTES),
1388            }));
1389            continue;
1390        }
1391        // Sanitize filename: strip directory components.
1392        let safe = std::path::Path::new(&original)
1393            .file_name()
1394            .map(|s| s.to_string_lossy().to_string())
1395            .unwrap_or_else(|| "upload.bin".into());
1396        let dest = dir.join(&safe);
1397        if let Err(e) = std::fs::write(&dest, &data) {
1398            rejected
1399                .push(serde_json::json!({"name": safe, "reason": format!("write error: {}", e)}));
1400            continue;
1401        }
1402        let ext = std::path::Path::new(&safe)
1403            .extension()
1404            .map(|s| s.to_string_lossy().to_string())
1405            .unwrap_or_default();
1406        let kind = classify_attachment(&content_type, &ext);
1407        accepted.push(AttachmentMetadata {
1408            name: safe.clone(),
1409            path: dest.to_string_lossy().to_string(),
1410            size: data.len() as u64,
1411            mime: content_type,
1412            kind,
1413        });
1414    }
1415
1416    axum::extract::Json(serde_json::json!({
1417        "ok": !accepted.is_empty(),
1418        "accepted": accepted,
1419        "rejected": rejected,
1420        "limit_bytes": MAX_ATTACHMENT_BYTES,
1421    }))
1422}
1423
1424async fn list_artifacts() -> axum::extract::Json<serde_json::Value> {
1425    let dir = attachments_dir();
1426    let mut items: Vec<AttachmentMetadata> = Vec::new();
1427    if let Ok(entries) = std::fs::read_dir(&dir) {
1428        for entry in entries.flatten() {
1429            let path = entry.path();
1430            if !path.is_file() {
1431                continue;
1432            }
1433            let name = path
1434                .file_name()
1435                .map(|s| s.to_string_lossy().to_string())
1436                .unwrap_or_default();
1437            let ext = path
1438                .extension()
1439                .map(|s| s.to_string_lossy().to_string())
1440                .unwrap_or_default();
1441            let mime = mime_guess::from_path(&path)
1442                .first_or_octet_stream()
1443                .to_string();
1444            let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
1445            let kind = classify_attachment(&mime, &ext);
1446            items.push(AttachmentMetadata {
1447                name,
1448                path: path.to_string_lossy().to_string(),
1449                size,
1450                mime,
1451                kind,
1452            });
1453        }
1454    }
1455    axum::extract::Json(serde_json::json!({
1456        "ok": true,
1457        "items": items,
1458        "dir": dir.to_string_lossy().to_string(),
1459    }))
1460}
1461
1462/// `GET /skills` — return the local skill library so the drawer panel can list
1463/// names + descriptions. Reads from the same dir the runtime uses.
1464async fn list_skills() -> axum::extract::Json<serde_json::Value> {
1465    use crate::capabilities::FsSkillLibrary;
1466    let skills_dir = dirs::config_dir()
1467        .unwrap_or_else(|| std::path::PathBuf::from("."))
1468        .join("sparrow")
1469        .join("skills");
1470    let lib = FsSkillLibrary::new(skills_dir.clone());
1471    let scanned = lib.scan();
1472    let skills: Vec<serde_json::Value> = scanned
1473        .into_iter()
1474        .map(|s| {
1475            serde_json::json!({
1476                "name": s.name,
1477                "description": s.description,
1478                "uses": s.usage_count,
1479                "score": s.score,
1480                "auto_generated": s.auto_generated,
1481            })
1482        })
1483        .collect();
1484    axum::extract::Json(serde_json::json!({
1485        "ok": true,
1486        "skills": skills,
1487        "dir": skills_dir.to_string_lossy().to_string(),
1488    }))
1489}
1490
1491#[derive(serde::Deserialize)]
1492struct CreateAgentReq {
1493    name: String,
1494    role: Option<String>,
1495    description: Option<String>,
1496    model: Option<String>,
1497    color_key: Option<String>,
1498    soul: Option<String>,     // raw markdown for .soul.toml personality
1499    agent_md: Option<String>, // raw markdown for .agent.md long-form
1500    allowed_tools: Option<Vec<String>>,
1501}
1502
1503/// `POST /agents` — create a persistent agent on disk under the user's local
1504/// `./agents/` dir (workdir). Writes `<name>.soul.toml` (TOML config) and
1505/// optionally `<name>.agent.md` (long-form persona / instructions).
1506async fn create_agent(
1507    axum::extract::Json(req): axum::extract::Json<CreateAgentReq>,
1508) -> axum::extract::Json<serde_json::Value> {
1509    let name = req.name.trim();
1510    if name.is_empty()
1511        || name.contains(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
1512    {
1513        return axum::extract::Json(serde_json::json!({
1514            "ok": false,
1515            "message": "agent name must be ascii alphanumeric/_/- only",
1516        }));
1517    }
1518    let dir = std::env::current_dir()
1519        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1520        .join("agents");
1521    if let Err(e) = std::fs::create_dir_all(&dir) {
1522        return axum::extract::Json(serde_json::json!({
1523            "ok": false,
1524            "message": format!("could not create agents dir: {e}"),
1525        }));
1526    }
1527    let role = req.role.as_deref().unwrap_or("custom agent");
1528    let description = req.description.as_deref().unwrap_or("");
1529    let color_key = req.color_key.as_deref().unwrap_or("steel");
1530    let model = req.model.as_deref().unwrap_or("");
1531    let allowed_tools = req.allowed_tools.unwrap_or_default();
1532    let soul_path = dir.join(format!("{name}.soul.toml"));
1533    let soul = req.soul.unwrap_or_else(|| {
1534        let tools_block = if allowed_tools.is_empty() {
1535            String::new()
1536        } else {
1537            format!(
1538                "allowed_tools = [{}]\n",
1539                allowed_tools
1540                    .iter()
1541                    .map(|t| format!("\"{}\"", t.replace('"', "\\\"")))
1542                    .collect::<Vec<_>>()
1543                    .join(", ")
1544            )
1545        };
1546        format!(
1547            "# Sparrow persistent agent\n\
1548             name = \"{name}\"\n\
1549             role = \"{role}\"\n\
1550             description = \"\"\"{description}\"\"\"\n\
1551             color_key = \"{color_key}\"\n\
1552             {model_line}\
1553             {tools_block}\n\
1554             [personality]\n\
1555             tone = \"concise, competent, direct\"\n",
1556            name = name,
1557            role = role.replace('"', "\\\""),
1558            description = description.replace('"', "\\\""),
1559            color_key = color_key,
1560            model_line = if model.is_empty() {
1561                String::new()
1562            } else {
1563                format!("model = \"{}\"\n", model.replace('"', "\\\""))
1564            },
1565            tools_block = tools_block,
1566        )
1567    });
1568    if let Err(e) = std::fs::write(&soul_path, soul) {
1569        return axum::extract::Json(serde_json::json!({
1570            "ok": false,
1571            "message": format!("could not write soul file: {e}"),
1572        }));
1573    }
1574    if let Some(md) = req.agent_md {
1575        if !md.trim().is_empty() {
1576            let md_path = dir.join(format!("{name}.agent.md"));
1577            if let Err(e) = std::fs::write(&md_path, md) {
1578                return axum::extract::Json(serde_json::json!({
1579                    "ok": false,
1580                    "message": format!("could not write agent.md: {e}"),
1581                }));
1582            }
1583        }
1584    }
1585    axum::extract::Json(serde_json::json!({
1586        "ok": true,
1587        "name": name,
1588        "soul_path": soul_path.to_string_lossy().to_string(),
1589        "message": "agent created",
1590    }))
1591}
1592
1593/// `DELETE /agents/:name` — remove a persistent agent's local files.
1594async fn delete_agent(
1595    axum::extract::Path(name): axum::extract::Path<String>,
1596) -> axum::extract::Json<serde_json::Value> {
1597    if name.is_empty()
1598        || name.contains(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
1599    {
1600        return axum::extract::Json(serde_json::json!({
1601            "ok": false,
1602            "message": "invalid agent name",
1603        }));
1604    }
1605    let dir = std::env::current_dir()
1606        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1607        .join("agents");
1608    let soul = dir.join(format!("{name}.soul.toml"));
1609    let md = dir.join(format!("{name}.agent.md"));
1610    let mut removed = 0u32;
1611    if soul.exists() {
1612        let _ = std::fs::remove_file(&soul);
1613        removed += 1;
1614    }
1615    if md.exists() {
1616        let _ = std::fs::remove_file(&md);
1617        removed += 1;
1618    }
1619    axum::extract::Json(serde_json::json!({
1620        "ok": removed > 0,
1621        "removed": removed,
1622        "message": if removed > 0 { "deleted" } else { "not found" },
1623    }))
1624}
1625
1626/// `GET /agents` — return every installed agent so the WebView swarm row and
1627/// the composer's `@<name>` picker can render the real list (Sprint 1, item
1628/// dynamic WebView swarm row. Status is "idle" by default; the runtime updates a
1629/// shared `Arc<Mutex<AgentRuntimeState>>` later — for v0.3.0 we ship the
1630/// listing as a cold view backed by `FsAgentStore::list()`.
1631async fn list_agents() -> axum::extract::Json<serde_json::Value> {
1632    use crate::agent::{AgentStore, FsAgentStore};
1633
1634    let agents_dir = dirs::config_dir()
1635        .unwrap_or_else(|| std::path::PathBuf::from("."))
1636        .join("sparrow")
1637        .join("agents");
1638
1639    // Collect souls from user config dir + local repo `agents/` dir + `.sparrow/agents/`.
1640    let extra_dirs: Vec<std::path::PathBuf> = [
1641        std::env::current_dir().ok().map(|d| d.join("agents")),
1642        std::env::current_dir()
1643            .ok()
1644            .map(|d| d.join(".sparrow").join("agents")),
1645    ]
1646    .into_iter()
1647    .flatten()
1648    .filter(|p| p.is_dir())
1649    .collect();
1650
1651    let store = FsAgentStore::new(agents_dir.clone());
1652    let mut souls = store.list();
1653    let mut seen: std::collections::HashSet<String> =
1654        souls.iter().map(|s| s.name.clone()).collect();
1655    for dir in &extra_dirs {
1656        let extra = FsAgentStore::new(dir.clone()).list();
1657        for s in extra {
1658            if seen.insert(s.name.clone()) {
1659                souls.push(s);
1660            }
1661        }
1662    }
1663
1664    let items: Vec<serde_json::Value> = souls
1665        .into_iter()
1666        .map(|s| {
1667            // Pick a colour key the WebView already knows about; falls back to
1668            // the canonical triad if the agent uses one of those role names.
1669            let color_key = match s.role.to_lowercase().as_str() {
1670                "planner" => "planner",
1671                "coder" => "coder",
1672                "verifier" => "verifier",
1673                _ => s
1674                    .color
1675                    .as_deref()
1676                    .map(classify_agent_color)
1677                    .unwrap_or("steel"),
1678            };
1679            serde_json::json!({
1680                "name": s.name,
1681                "role": s.role,
1682                "description": s.description,
1683                "status": "idle",
1684                "msg": "",
1685                "color_key": color_key,
1686            })
1687        })
1688        .collect();
1689
1690    axum::extract::Json(serde_json::json!({
1691        "ok": true,
1692        "dir": agents_dir.to_string_lossy(),
1693        "agents": items,
1694    }))
1695}
1696
1697/// Maps the optional `color` field of a `Soul` to one of the known WebView
1698/// theme tokens. Unknown values fall back to `steel`.
1699pub fn classify_agent_color(raw: &str) -> &'static str {
1700    match raw.trim().to_lowercase().as_str() {
1701        "planner" | "blue" => "planner",
1702        "coder" | "teal" | "agent" => "coder",
1703        "verifier" | "sand" => "verifier",
1704        "gold" | "yellow" => "gold",
1705        "coral" | "red" => "coral",
1706        _ => "steel",
1707    }
1708}
1709
1710#[derive(serde::Deserialize)]
1711struct LoadSessionRequest {
1712    id: String,
1713}
1714
1715async fn load_session(
1716    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1717    axum::extract::Json(req): axum::extract::Json<LoadSessionRequest>,
1718) -> axum::extract::Json<RunResponse> {
1719    let id = req.id.trim();
1720    if id.is_empty() {
1721        return axum::extract::Json(RunResponse {
1722            ok: false,
1723            message: "empty session id".into(),
1724        });
1725    }
1726    // Reuse the same command channel sentinel mechanism that powers
1727    // __reset_conversation__ — main.rs interprets __load_session__:<id> and
1728    // swaps the live `conv_history` with the loaded session's messages.
1729    let sentinel = format!("__load_session__:{}", id);
1730    match &state.command_tx {
1731        Some(tx) if tx.send(sentinel).is_ok() => axum::extract::Json(RunResponse {
1732            ok: true,
1733            message: "session load requested".into(),
1734        }),
1735        _ => axum::extract::Json(RunResponse {
1736            ok: false,
1737            message: "console command channel unavailable".into(),
1738        }),
1739    }
1740}
1741
1742async fn list_sessions() -> axum::extract::Json<serde_json::Value> {
1743    // Resolve the same DB path the CLI uses. Failures degrade to an empty list
1744    // rather than 500'ing the WebView panel.
1745    let db_path = session_db_path();
1746    let store = match crate::runtime::session::SessionStore::open(&db_path) {
1747        Ok(s) => s,
1748        Err(e) => {
1749            return axum::extract::Json(serde_json::json!({
1750                "ok": false,
1751                "message": format!("could not open session db: {}", e),
1752                "db_path": db_path.to_string_lossy(),
1753                "sessions": [],
1754            }));
1755        }
1756    };
1757    let sessions = store.list();
1758    axum::extract::Json(serde_json::json!({
1759        "ok": true,
1760        "db_path": db_path.to_string_lossy(),
1761        "sessions": sessions,
1762    }))
1763}
1764
1765async fn get_history(
1766    axum::extract::Query(query): axum::extract::Query<HistoryQuery>,
1767) -> axum::extract::Json<HistoryResponse> {
1768    let db_path = session_db_path();
1769    let store = match crate::runtime::session::SessionStore::open(&db_path) {
1770        Ok(s) => s,
1771        Err(e) => {
1772            return axum::extract::Json(HistoryResponse {
1773                ok: false,
1774                message: format!("could not open session db: {}", e),
1775                inputs: Vec::new(),
1776            });
1777        }
1778    };
1779    axum::extract::Json(HistoryResponse {
1780        ok: true,
1781        message: "loaded".into(),
1782        inputs: store.recent_inputs(query.limit.unwrap_or(50)),
1783    })
1784}
1785
1786fn session_db_path() -> std::path::PathBuf {
1787    dirs::state_dir()
1788        .or_else(dirs::data_local_dir)
1789        .or_else(dirs::data_dir)
1790        .unwrap_or_else(|| std::path::PathBuf::from("."))
1791        .join("sparrow")
1792        .join("sessions.db")
1793}
1794
1795async fn get_security(
1796    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1797) -> axum::extract::Json<serde_json::Value> {
1798    let Some(shared) = &state.config else {
1799        return axum::extract::Json(serde_json::json!({
1800            "ok": false,
1801            "message": "config unavailable",
1802        }));
1803    };
1804    let cfg = shared.read().expect("config lock poisoned").clone();
1805    let audit = crate::security::SecurityAudit::run(&cfg, &cfg.hooks);
1806    axum::extract::Json(serde_json::json!({
1807        "ok": true,
1808        "audit": audit,
1809    }))
1810}
1811
1812async fn get_permissions(
1813    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1814) -> axum::extract::Json<PermissionsResponse> {
1815    let Some(shared) = &state.config else {
1816        return axum::extract::Json(PermissionsResponse {
1817            ok: false,
1818            message: "config unavailable".into(),
1819            permissions: None,
1820            persisted_tools: std::collections::HashMap::new(),
1821        });
1822    };
1823    let cfg = shared.read().expect("config lock poisoned").clone();
1824    let persisted_tools = cfg.permissions.store.to_api_map();
1825    axum::extract::Json(PermissionsResponse {
1826        ok: true,
1827        message: "loaded".into(),
1828        permissions: Some(cfg.permissions),
1829        persisted_tools,
1830    })
1831}
1832
1833async fn save_permissions(
1834    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1835    axum::extract::Json(req): axum::extract::Json<PermissionsRequest>,
1836) -> axum::extract::Json<RunResponse> {
1837    let Some(shared) = &state.config else {
1838        return axum::extract::Json(RunResponse {
1839            ok: false,
1840            message: "config unavailable".into(),
1841        });
1842    };
1843    let mut cfg = shared.write().expect("config lock poisoned");
1844
1845    // ── Global permission mode ───────────────────────────────────────────
1846    if let Some(mode) = req.mode.as_deref() {
1847        let Some(mode) = crate::permissions::PermissionMode::parse(mode) else {
1848            return axum::extract::Json(RunResponse {
1849                ok: false,
1850                message: "unknown permission mode".into(),
1851            });
1852        };
1853        cfg.defaults.autonomy = mode.autonomy_level();
1854        cfg.permissions.mode = mode;
1855    }
1856
1857    // ── Per-tool persisted decisions ──────────────────────────────────────
1858    if let Some(tools) = &req.tools {
1859        for (tool_name, decision_str) in tools {
1860            let decision = match decision_str.as_str() {
1861                "allow_always" => crate::event::Decision::AllowAlways,
1862                "allow_session" => crate::event::Decision::AllowSession,
1863                "deny" => crate::event::Decision::Deny,
1864                "ask_user" => crate::event::Decision::AskUser,
1865                _ => continue,
1866            };
1867            let config_dir = cfg.config_dir.clone();
1868            let _ = cfg
1869                .permissions
1870                .store
1871                .set_and_save(tool_name, &decision, &config_dir);
1872        }
1873    }
1874
1875    let saved = cfg.clone();
1876    let store = FsConfigStore::new(saved.config_dir.clone());
1877    if let Err(err) = store.save(&saved) {
1878        return axum::extract::Json(RunResponse {
1879            ok: false,
1880            message: format!("permissions save failed: {}", err),
1881        });
1882    }
1883    axum::extract::Json(RunResponse {
1884        ok: true,
1885        message: "permissions saved".into(),
1886    })
1887}
1888
1889fn parse_autonomy(value: Option<&str>) -> Option<crate::event::AutonomyLevel> {
1890    match value.map(|s| s.trim().to_lowercase()).as_deref() {
1891        Some("supervised") => Some(crate::event::AutonomyLevel::Supervised),
1892        Some("trusted") => Some(crate::event::AutonomyLevel::Trusted),
1893        Some("autonomous") => Some(crate::event::AutonomyLevel::Autonomous),
1894        _ => None,
1895    }
1896}
1897
1898// ─── Provider model scan ──────────────────────────────────────────────────────
1899
1900#[derive(serde::Deserialize)]
1901struct ScanRequest {
1902    provider: String,
1903}
1904
1905#[derive(serde::Serialize)]
1906struct ScanResponse {
1907    ok: bool,
1908    message: String,
1909    models: Vec<String>,
1910}
1911
1912async fn scan_provider_models(
1913    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1914    axum::extract::Json(req): axum::extract::Json<ScanRequest>,
1915) -> axum::extract::Json<ScanResponse> {
1916    use crate::config::providers::find_provider;
1917
1918    let provider_id = req.provider.trim().to_string();
1919
1920    let Some(def) = find_provider(&provider_id) else {
1921        return axum::extract::Json(ScanResponse {
1922            ok: false,
1923            message: format!("Unknown provider: {}", provider_id),
1924            models: vec![],
1925        });
1926    };
1927
1928    // Resolve API key: auth store -> env var
1929    let api_key = {
1930        let key_from_store = state.config.as_ref().and_then(|cfg| {
1931            let c = cfg.read().ok()?;
1932            let auth = crate::auth::store::ChainedAuthStore::new(c.config_dir.clone());
1933            match auth.get(&provider_id) {
1934                Some(crate::auth::Credential::ApiKey(k)) => Some(k.expose_secret().to_string()),
1935                _ => None,
1936            }
1937        });
1938        let key_from_env = def
1939            .api_key_env
1940            .as_deref()
1941            .and_then(|env| std::env::var(env).ok());
1942        key_from_store.or(key_from_env).unwrap_or_default()
1943    };
1944
1945    match crate::provider::discovery::discover_models(&def.adapter, &def.base_url, &api_key).await {
1946        Ok(models) => {
1947            let count = models.len();
1948            axum::extract::Json(ScanResponse {
1949                ok: true,
1950                message: format!("Found {} model(s) for {}", count, def.label),
1951                models,
1952            })
1953        }
1954        Err(err) => axum::extract::Json(ScanResponse {
1955            ok: false,
1956            message: format!("Scan failed: {}", err),
1957            models: vec![],
1958        }),
1959    }
1960}
1961
1962// ─── Routing config get/set ───────────────────────────────────────────────────
1963
1964#[derive(serde::Serialize)]
1965struct RoutingResponse {
1966    ok: bool,
1967    preferred_provider: Option<String>,
1968    auto_discover: bool,
1969    all_providers: Vec<String>,
1970}
1971
1972#[derive(serde::Deserialize)]
1973struct RoutingRequest {
1974    /// Set to "" or null to clear the preference.
1975    preferred_provider: Option<String>,
1976    auto_discover: Option<bool>,
1977}
1978
1979async fn get_routing(
1980    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1981) -> axum::extract::Json<RoutingResponse> {
1982    use crate::config::providers::provider_registry;
1983
1984    let all_providers: Vec<String> = provider_registry().iter().map(|p| p.id.clone()).collect();
1985
1986    let Some(shared) = &state.config else {
1987        return axum::extract::Json(RoutingResponse {
1988            ok: false,
1989            preferred_provider: None,
1990            auto_discover: true,
1991            all_providers,
1992        });
1993    };
1994
1995    let cfg = shared.read().expect("config lock poisoned");
1996    axum::extract::Json(RoutingResponse {
1997        ok: true,
1998        preferred_provider: cfg.routing.preferred_provider.clone(),
1999        auto_discover: cfg.routing.auto_discover,
2000        all_providers,
2001    })
2002}
2003
2004async fn save_routing(
2005    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2006    axum::extract::Json(req): axum::extract::Json<RoutingRequest>,
2007) -> axum::extract::Json<RunResponse> {
2008    let Some(shared) = &state.config else {
2009        return axum::extract::Json(RunResponse {
2010            ok: false,
2011            message: "config unavailable".into(),
2012        });
2013    };
2014
2015    {
2016        let mut cfg = shared.write().expect("config lock poisoned");
2017
2018        // preferred_provider: empty string or missing = clear
2019        cfg.routing.preferred_provider = req
2020            .preferred_provider
2021            .map(|s| s.trim().to_string())
2022            .filter(|s| !s.is_empty());
2023
2024        if let Some(ad) = req.auto_discover {
2025            cfg.routing.auto_discover = ad;
2026        }
2027
2028        let saved = cfg.clone();
2029        let store = FsConfigStore::new(saved.config_dir.clone());
2030        if let Err(err) = store.save(&saved) {
2031            return axum::extract::Json(RunResponse {
2032                ok: false,
2033                message: format!("save failed: {}", err),
2034            });
2035        }
2036    }
2037
2038    axum::extract::Json(RunResponse {
2039        ok: true,
2040        message: "Routing preferences saved.".into(),
2041    })
2042}
2043
2044async fn handle_ws(
2045    mut socket: axum::extract::ws::WebSocket,
2046    mut event_rx: tokio::sync::broadcast::Receiver<Event>,
2047    snapshot: Vec<Event>,
2048) {
2049    // Replay the current run so far, so a refresh mid-run never shows a
2050    // blank feed. The snapshot only holds public events.
2051    for event in &snapshot {
2052        if let Ok(json) = serde_json::to_string(event) {
2053            use axum::extract::ws::Message;
2054            if socket.send(Message::Text(json.into())).await.is_err() {
2055                return;
2056            }
2057        }
2058    }
2059    loop {
2060        tokio::select! {
2061            result = event_rx.recv() => {
2062                match result {
2063                    Ok(event) => {
2064                        if !event.is_public() {
2065                            continue;
2066                        }
2067                        if let Ok(json) = serde_json::to_string(&event) {
2068                            use axum::extract::ws::Message;
2069                            if socket.send(Message::Text(json.into())).await.is_err() {
2070                                break;
2071                            }
2072                        }
2073                    }
2074                    Err(_) => break,
2075                }
2076            }
2077            _ = tokio::time::sleep(tokio::time::Duration::from_secs(30)) => {
2078                // Ping keep-alive
2079                use axum::extract::ws::Message;
2080                if socket.send(Message::Ping(vec![])).await.is_err() {
2081                    break;
2082                }
2083            }
2084        }
2085    }
2086}
2087
2088#[cfg(test)]
2089mod tests {
2090    use super::*;
2091
2092    #[test]
2093    fn webview_cli_args_maps_model_alias() {
2094        assert_eq!(
2095            webview_cli_args("/models").unwrap(),
2096            vec!["model".to_string(), "--list".to_string()]
2097        );
2098    }
2099
2100    #[test]
2101    fn webview_cli_args_keeps_quoted_arguments() {
2102        assert_eq!(
2103            webview_cli_args("/auth add \"open router\"").unwrap(),
2104            vec![
2105                "auth".to_string(),
2106                "add".to_string(),
2107                "open router".to_string()
2108            ]
2109        );
2110    }
2111
2112    #[test]
2113    fn webview_cli_args_joins_run_task() {
2114        assert_eq!(
2115            webview_cli_args("/run analyse le repo github").unwrap(),
2116            vec!["run".to_string(), "analyse le repo github".to_string()]
2117        );
2118    }
2119
2120    #[test]
2121    fn webview_cli_blocks_interactive_commands() {
2122        let args = webview_cli_args("/console --port 9339").unwrap();
2123        assert!(blocked_webview_cli_command(&args).is_some());
2124        let args = webview_cli_args("/gateway start").unwrap();
2125        assert!(blocked_webview_cli_command(&args).is_some());
2126    }
2127}