Skip to main content

sparrow/
console.rs

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