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