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