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// ─── Bind address resolution (security: loopback by default) ────────────────────
19//
20// v0.8.1 D1/D2/D3: the console used to bind `0.0.0.0` unconditionally while
21// `--bind` was parsed but never read, exposing the agent (run/agents/file
22// writes) to the whole LAN. We now bind 127.0.0.1 by default, honour an
23// explicit `--bind`, validate the value, refuse to start over an existing
24// console, and warn loudly when the bind is non-loopback.
25
26/// Resolved bind target plus whether it is reachable beyond loopback.
27#[derive(Debug)]
28pub struct BindTarget {
29    pub addr: SocketAddr,
30    pub is_public: bool,
31}
32
33/// Resolve the bind address from an optional `--bind` value and a port.
34///
35/// - `None` → `127.0.0.1` (loopback, safe default).
36/// - A bare IP (`"0.0.0.0"`, `"192.168.1.5"`, `"::1"`) → that IP.
37/// - Anything containing a port (`"127.0.0.1:9876"`) or not a valid IP → error.
38pub fn resolve_bind_addr(bind: Option<&str>, port: u16) -> anyhow::Result<BindTarget> {
39    use std::net::IpAddr;
40    let ip: IpAddr = match bind.map(str::trim).filter(|s| !s.is_empty()) {
41        None => IpAddr::from([127, 0, 0, 1]),
42        Some(raw) => raw.parse::<IpAddr>().map_err(|_| {
43            anyhow::anyhow!(
44                "--bind attend une adresse IP seule (ex. 127.0.0.1 ou 0.0.0.0), \
45                 pas « {raw} ». Le port se règle avec --port."
46            )
47        })?,
48    };
49    let is_public = !ip.is_loopback();
50    Ok(BindTarget {
51        addr: SocketAddr::new(ip, port),
52        is_public,
53    })
54}
55
56/// Probe `/healthz` on the loopback interface for `port`. Returns true when an
57/// existing Sparrow console already answers there, so the caller can refuse to
58/// start a duplicate instead of dying with a raw OS "address in use" error.
59pub async fn console_already_running(port: u16) -> bool {
60    let url = format!("http://127.0.0.1:{port}/healthz");
61    let client = match reqwest::Client::builder()
62        .timeout(Duration::from_millis(500))
63        .build()
64    {
65        Ok(c) => c,
66        Err(_) => return false,
67    };
68    match client.get(&url).send().await {
69        Ok(resp) => resp.status().is_success(),
70        Err(_) => false,
71    }
72}
73
74// ─── Embedded HTML ─────────────────────────────────────────────────────────────
75//
76// console.html is `include_str!`d into the binary so a release build ships as
77// a single file. The drawback: any edit to console.html requires a fresh
78// `cargo build` — a plain WebView reload (Ctrl+R) re-fetches the same baked-in
79// bytes. To make the WebView dev loop tight, set the env var
80// `SPARROW_CONSOLE_HTML` to a path on disk and that file is served instead.
81
82const CONSOLE_HTML_EMBEDDED: &str = include_str!("../console.html");
83
84fn console_html() -> std::borrow::Cow<'static, str> {
85    if let Ok(path) = std::env::var("SPARROW_CONSOLE_HTML") {
86        if !path.trim().is_empty() {
87            match std::fs::read_to_string(&path) {
88                Ok(contents) => return std::borrow::Cow::Owned(contents),
89                Err(e) => {
90                    tracing::warn!(
91                        "SPARROW_CONSOLE_HTML={} unreadable ({}); falling back to embedded HTML",
92                        path,
93                        e
94                    );
95                }
96            }
97        }
98    }
99    std::borrow::Cow::Borrowed(CONSOLE_HTML_EMBEDDED)
100}
101
102fn looks_like_api_key(value: &str) -> bool {
103    let value = value.trim();
104    value.starts_with("sk-")
105        || value.starts_with("nvapi-")
106        || value.starts_with("gsk_")
107        || value.starts_with("sk-or-")
108        || value.len() > 40 && !value.chars().all(|c| c.is_ascii_uppercase() || c == '_')
109}
110
111// ─── WebView server ────────────────────────────────────────────────────────────
112
113pub struct WebViewServer {
114    addr: SocketAddr,
115    event_tx: broadcast::Sender<Event>,
116    command_tx: Option<mpsc::UnboundedSender<String>>,
117    config: Option<Arc<RwLock<Config>>>,
118    approvals: Option<Arc<WebApprovalBroker>>,
119    skills: Option<Arc<dyn SkillLibrary>>,
120    memory: Option<Arc<dyn Memory>>,
121    agent_store: Option<Arc<dyn AgentStore>>,
122}
123
124impl WebViewServer {
125    #[allow(clippy::too_many_arguments)]
126    pub fn new(
127        addr: SocketAddr,
128        event_tx: broadcast::Sender<Event>,
129        command_tx: Option<mpsc::UnboundedSender<String>>,
130        config: Option<Arc<RwLock<Config>>>,
131        approvals: Option<Arc<WebApprovalBroker>>,
132        skills: Option<Arc<dyn SkillLibrary>>,
133        memory: Option<Arc<dyn Memory>>,
134        agent_store: Option<Arc<dyn AgentStore>>,
135    ) -> Self {
136        Self {
137            addr,
138            event_tx,
139            command_tx,
140            config,
141            approvals,
142            skills,
143            memory,
144            agent_store,
145        }
146    }
147
148    pub async fn serve(&self) -> anyhow::Result<()> {
149        use axum::{
150            Router,
151            extract::{State, ws::WebSocketUpgrade},
152            response::Html,
153            routing::{get, post},
154        };
155
156        let event_tx = self.event_tx.clone();
157
158        // Replay-on-connect buffer: a client that opens (or refreshes) the
159        // cockpit mid-run must see the run so far, not a blank feed. Keep the
160        // current run's public events in a bounded ring (cleared on each
161        // RunStarted) and send it to every new WebSocket before going live.
162        let recent: Arc<parking_lot::Mutex<std::collections::VecDeque<Event>>> =
163            Arc::new(parking_lot::Mutex::new(std::collections::VecDeque::new()));
164        {
165            let recent = recent.clone();
166            let mut brx = event_tx.subscribe();
167            tokio::spawn(async move {
168                const RING_CAP: usize = 800;
169                loop {
170                    match brx.recv().await {
171                        Ok(ev) => {
172                            if !ev.is_public() {
173                                continue;
174                            }
175                            let mut ring = recent.lock();
176                            if matches!(ev, Event::RunStarted { .. }) {
177                                ring.clear();
178                            }
179                            if ring.len() >= RING_CAP {
180                                ring.pop_front();
181                            }
182                            ring.push_back(ev);
183                        }
184                        // Lagged: skip dropped events; Closed: stop.
185                        Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
186                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
187                    }
188                }
189            });
190        }
191
192        let state = Arc::new(AppState {
193            event_tx: event_tx.clone(),
194            command_tx: self.command_tx.clone(),
195            config: self.config.clone(),
196            approvals: self.approvals.clone(),
197            skills: self.skills.clone(),
198            memory: self.memory.clone(),
199            agent_store: self.agent_store.clone(),
200        });
201
202        let app = Router::new()
203            // Reads from disk if `SPARROW_CONSOLE_HTML` is set (live-reload
204            // friendly: Ctrl+R picks up edits without recompile). Otherwise
205            // serves the include_str!()'d copy baked at compile time.
206            .route("/", get(|| async { Html(console_html().into_owned()) }))
207            // /healthz: lightweight liveness probe used by the VS Code
208            // extension and any external orchestrator to know the cockpit is
209            // up before opening a webview pointing at it.
210            .route(
211                "/healthz",
212                get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
213            )
214            .route("/run", post(run_task))
215            .route("/plan", post(plan_task))
216            .route("/cli", post(run_cli_command))
217            .route("/commands", get(get_commands))
218            .route("/memory", get(get_memory))
219            .route("/plugins", get(get_plugins))
220            .route("/tools", get(get_tools))
221            .route("/models", get(list_models))
222            .route("/status", get(get_status))
223            .route("/file", get(read_file))
224            .route("/conversation/reset", post(reset_conversation))
225            .route("/stop", post(stop_run))
226            .route("/approval", post(resolve_approval))
227            .route("/config", get(get_config).post(save_provider))
228            .route("/permissions", get(get_permissions).post(save_permissions))
229            .route("/security", get(get_security))
230            .route("/sessions", get(list_sessions))
231            .route("/sessions/load", post(load_session))
232            .route("/history", get(get_history))
233            .route("/agents", get(list_agents).post(create_agent))
234            .route("/agents/:name", axum::routing::delete(delete_agent))
235            .route("/skills", get(list_skills))
236            .route("/upload", post(upload_attachment))
237            .route("/artifacts", get(list_artifacts))
238            .route("/providers/scan", post(scan_provider_models))
239            .route("/routing", get(get_routing).post(save_routing))
240            .route("/todos", get(list_todos))
241            .route("/runs", get(list_runs))
242            .route("/intel/digests", get(get_intel_digests))
243            .route("/intel/backlog", get(get_intel_backlog))
244            .route("/preview/scan", get(scan_preview_servers))
245            .route("/replay", get(replay_run))
246            .route("/replays", get(list_replays))
247            .route("/mcp/list", get(list_mcp_servers))
248            .route("/hooks", get(list_hooks))
249            .route("/update/check", get(check_update_api))
250            .route(
251                "/ws",
252                get(
253                    move |ws: WebSocketUpgrade, State(state): State<Arc<AppState>>| {
254                        let recent = recent.clone();
255                        async move {
256                            // Subscribe BEFORE snapshotting so no event falls in
257                            // the gap (a microsecond overlap may duplicate one
258                            // trailing event — harmless for a log feed).
259                            let rx = state.event_tx.subscribe();
260                            let snapshot: Vec<Event> = recent.lock().iter().cloned().collect();
261                            // v0.9 Pilier 2: resolve the experience mode/language
262                            // so the WS layer can attach a plain-language `human`
263                            // field to each event in simple mode (one table,
264                            // server-side — no duplication on the frontend).
265                            let (simple, lang) = state
266                                .config
267                                .as_ref()
268                                .and_then(|c| {
269                                    c.read().ok().map(|cfg| {
270                                        (cfg.experience.is_simple(), cfg.experience.lang())
271                                    })
272                                })
273                                .unwrap_or((true, crate::humanize::Lang::Fr));
274                            ws.on_upgrade(move |socket| {
275                                handle_ws(socket, rx, snapshot, simple, lang)
276                            })
277                        }
278                    },
279                ),
280            )
281            .with_state(state);
282
283        let listener = tokio::net::TcpListener::bind(self.addr).await?;
284        tracing::info!("WebView console: http://{}", self.addr);
285
286        axum::serve(listener, app).await?;
287        Ok(())
288    }
289}
290
291#[derive(Clone)]
292struct AppState {
293    event_tx: broadcast::Sender<Event>,
294    command_tx: Option<mpsc::UnboundedSender<String>>,
295    config: Option<Arc<RwLock<Config>>>,
296    approvals: Option<Arc<WebApprovalBroker>>,
297    skills: Option<Arc<dyn SkillLibrary>>,
298    memory: Option<Arc<dyn Memory>>,
299    agent_store: Option<Arc<dyn AgentStore>>,
300}
301
302#[derive(Default)]
303pub struct WebApprovalBroker {
304    pending: Mutex<HashMap<String, oneshot::Sender<Decision>>>,
305}
306
307impl WebApprovalBroker {
308    pub fn new() -> Self {
309        Self::default()
310    }
311
312    pub async fn resolve(&self, id: &str, decision: Decision) -> bool {
313        let mut pending = self.pending.lock().await;
314        pending
315            .remove(id)
316            .map(|tx| tx.send(decision).is_ok())
317            .unwrap_or(false)
318    }
319}
320
321#[async_trait::async_trait]
322impl ApprovalHandler for WebApprovalBroker {
323    async fn request_approval(&self, request: ApprovalRequest) -> Decision {
324        let (tx, rx) = oneshot::channel();
325        let id = request.id.clone();
326        {
327            let mut pending = self.pending.lock().await;
328            pending.insert(id.clone(), tx);
329        }
330        match tokio::time::timeout(Duration::from_secs(300), rx).await {
331            Ok(Ok(decision)) => decision,
332            _ => {
333                let mut pending = self.pending.lock().await;
334                pending.remove(&id);
335                Decision::Deny
336            }
337        }
338    }
339}
340
341#[derive(serde::Deserialize)]
342struct RunRequest {
343    task: String,
344    #[serde(default)]
345    model_override: Option<String>,
346    #[serde(default)]
347    agent_name: Option<String>,
348}
349
350#[derive(serde::Serialize)]
351struct RunResponse {
352    ok: bool,
353    message: String,
354}
355
356#[derive(serde::Serialize)]
357struct PlanResponse {
358    ok: bool,
359    message: String,
360    plan: Option<ReadOnlyPlan>,
361}
362
363#[derive(serde::Serialize)]
364struct CommandView {
365    name: String,
366    description: String,
367    usage: String,
368    source: String,
369}
370
371#[derive(serde::Serialize)]
372struct CommandsResponse {
373    ok: bool,
374    message: String,
375    commands: Vec<CommandView>,
376}
377
378#[derive(serde::Deserialize)]
379struct CliCommandRequest {
380    command: String,
381}
382
383#[derive(serde::Serialize)]
384struct CliCommandResponse {
385    ok: bool,
386    message: String,
387    status: Option<i32>,
388    stdout: String,
389    stderr: String,
390}
391
392#[derive(serde::Deserialize)]
393struct ApprovalResponseRequest {
394    id: String,
395    decision: String,
396}
397
398#[derive(serde::Serialize)]
399struct ProviderView {
400    name: String,
401    label: String,
402    adapter: String,
403    base_url: Option<String>,
404    models: Vec<String>,
405    tags: Vec<String>,
406    notes: String,
407    api_key_env: Option<String>,
408    has_credential: bool,
409    configured: bool,
410}
411
412#[derive(serde::Serialize)]
413struct BudgetView {
414    session_usd: f64,
415    daily_usd: f64,
416}
417
418#[derive(serde::Serialize)]
419struct ConfigResponse {
420    ok: bool,
421    message: String,
422    autonomy: String,
423    sandbox: String,
424    providers: Vec<ProviderView>,
425    #[serde(skip_serializing_if = "Option::is_none")]
426    budget: Option<BudgetView>,
427    #[serde(skip_serializing_if = "Option::is_none")]
428    workdir: Option<String>,
429    #[serde(skip_serializing_if = "Option::is_none")]
430    skills_count: Option<usize>,
431}
432
433#[derive(serde::Serialize)]
434struct PermissionsResponse {
435    ok: bool,
436    message: String,
437    permissions: Option<crate::permissions::PermissionConfig>,
438    /// Per-tool persisted decisions (for the WebView approval UI).
439    #[serde(default)]
440    persisted_tools: std::collections::HashMap<String, String>,
441}
442
443#[derive(serde::Deserialize)]
444struct PermissionsRequest {
445    mode: Option<String>,
446    /// Per-tool permission updates: {"web_search": "allow_always", "code_exec": "deny"}
447    tools: Option<std::collections::HashMap<String, String>>,
448}
449
450#[derive(serde::Serialize)]
451struct MemoryDocView {
452    kind: String,
453    chars: usize,
454    limit: usize,
455    updated_at: String,
456    content: String,
457}
458
459#[derive(serde::Serialize)]
460struct MemoryFactView {
461    id: String,
462    key: String,
463    value: String,
464    updated_at: String,
465}
466
467#[derive(serde::Serialize)]
468struct MemoryResponse {
469    ok: bool,
470    message: String,
471    stats: Option<crate::memory::MemoryStats>,
472    docs: Vec<MemoryDocView>,
473    facts: Vec<MemoryFactView>,
474}
475
476#[derive(serde::Serialize)]
477struct PluginView {
478    name: String,
479    version: String,
480    description: String,
481    commands: usize,
482    skills: usize,
483    hooks: usize,
484    allowed: bool,
485    warnings: Vec<String>,
486}
487
488#[derive(serde::Serialize)]
489struct PluginsResponse {
490    ok: bool,
491    message: String,
492    plugins: Vec<PluginView>,
493}
494
495#[derive(serde::Serialize)]
496struct ToolsResponse {
497    ok: bool,
498    message: String,
499    toolsets: Vec<String>,
500    tools: Vec<crate::tools::ToolMetadata>,
501    manifests: Vec<crate::tools::ToolManifest>,
502}
503
504#[derive(serde::Deserialize)]
505struct HistoryQuery {
506    limit: Option<usize>,
507}
508
509#[derive(serde::Serialize)]
510struct HistoryResponse {
511    ok: bool,
512    message: String,
513    inputs: Vec<String>,
514}
515
516#[derive(serde::Deserialize)]
517struct ProviderRequest {
518    #[serde(default)]
519    name: String,
520    #[serde(default)]
521    adapter: String,
522    base_url: Option<String>,
523    #[serde(default)]
524    models: Vec<String>,
525    api_key_env: Option<String>,
526    api_key: Option<String>,
527    autonomy: Option<String>,
528    sandbox: Option<String>,
529}
530
531async fn run_task(
532    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
533    axum::extract::Json(req): axum::extract::Json<RunRequest>,
534) -> axum::extract::Json<RunResponse> {
535    let task = req.task.trim().to_string();
536    if task.is_empty() {
537        return axum::extract::Json(RunResponse {
538            ok: false,
539            message: "empty task".into(),
540        });
541    }
542
543    // Prepend model override hint so the engine can parse it.
544    // The frontend sends "provider:model" — strip the provider prefix to
545    // match brain.id() which returns just the model name.
546    let dispatch = if let Some(m) = req.model_override.filter(|s| !s.is_empty()) {
547        let model_only = m.rsplit(':').next().unwrap_or(&m);
548        format!("__model:{model_only}__ {task}")
549    } else {
550        task
551    };
552    // Prepend agent identity override. When an agent is selected, load its
553    // soul and embed the identity so the engine runs with that persona.
554    let dispatch = if let Some(ref agent_name) = req.agent_name.filter(|s| !s.is_empty()) {
555        if let Some(ref store) = state.agent_store {
556            if let Some(soul) = store.get(agent_name) {
557                let identity = soul.to_identity();
558                // Base64-encode the personality to avoid delimiter collisions
559                use base64::{Engine as _, engine::general_purpose::STANDARD};
560                let b64 = STANDARD.encode(identity.personality.as_bytes());
561                format!(
562                    "__agent:{}__{}__{}__ {}",
563                    identity.name, identity.role, b64, dispatch
564                )
565            } else {
566                dispatch
567            }
568        } else {
569            dispatch
570        }
571    } else {
572        dispatch
573    };
574    match &state.command_tx {
575        Some(tx) if tx.send(dispatch).is_ok() => axum::extract::Json(RunResponse {
576            ok: true,
577            message: "queued".into(),
578        }),
579        _ => axum::extract::Json(RunResponse {
580            ok: false,
581            message: "console command channel unavailable".into(),
582        }),
583    }
584}
585
586async fn plan_task(
587    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
588    axum::extract::Json(req): axum::extract::Json<RunRequest>,
589) -> axum::extract::Json<PlanResponse> {
590    let task = req.task.trim().to_string();
591    if task.is_empty() {
592        return axum::extract::Json(PlanResponse {
593            ok: false,
594            message: "empty task".into(),
595            plan: None,
596        });
597    }
598    let commands = commands_for_state(&state);
599    let plan = crate::plan::build_read_only_plan(&task, &commands);
600    axum::extract::Json(PlanResponse {
601        ok: true,
602        message: "planned".into(),
603        plan: Some(plan),
604    })
605}
606
607async fn run_cli_command(
608    axum::extract::Json(req): axum::extract::Json<CliCommandRequest>,
609) -> axum::extract::Json<CliCommandResponse> {
610    let args = match webview_cli_args(&req.command) {
611        Ok(args) => args,
612        Err(message) => {
613            return axum::extract::Json(CliCommandResponse {
614                ok: false,
615                message,
616                status: None,
617                stdout: String::new(),
618                stderr: String::new(),
619            });
620        }
621    };
622
623    if let Some(message) = blocked_webview_cli_command(&args) {
624        return axum::extract::Json(CliCommandResponse {
625            ok: false,
626            message,
627            status: None,
628            stdout: String::new(),
629            stderr: String::new(),
630        });
631    }
632
633    let exe = match std::env::current_exe() {
634        Ok(exe) => exe,
635        Err(e) => {
636            return axum::extract::Json(CliCommandResponse {
637                ok: false,
638                message: format!("cannot locate Sparrow executable: {e}"),
639                status: None,
640                stdout: String::new(),
641                stderr: String::new(),
642            });
643        }
644    };
645
646    let child = match tokio::process::Command::new(exe)
647        .args(&args)
648        .env("SPARROW_WEBVIEW_CLI", "1")
649        .stdin(Stdio::null())
650        .stdout(Stdio::piped())
651        .stderr(Stdio::piped())
652        .kill_on_drop(true)
653        .spawn()
654    {
655        Ok(child) => child,
656        Err(e) => {
657            return axum::extract::Json(CliCommandResponse {
658                ok: false,
659                message: format!("failed to launch Sparrow command: {e}"),
660                status: None,
661                stdout: String::new(),
662                stderr: String::new(),
663            });
664        }
665    };
666
667    let output = match tokio::time::timeout(Duration::from_secs(45), child.wait_with_output()).await
668    {
669        Ok(Ok(output)) => output,
670        Ok(Err(e)) => {
671            return axum::extract::Json(CliCommandResponse {
672                ok: false,
673                message: format!("Sparrow command failed to finish: {e}"),
674                status: None,
675                stdout: String::new(),
676                stderr: String::new(),
677            });
678        }
679        Err(_) => {
680            return axum::extract::Json(CliCommandResponse {
681                ok: false,
682                message: "Sparrow command timed out after 45s".into(),
683                status: None,
684                stdout: String::new(),
685                stderr: String::new(),
686            });
687        }
688    };
689
690    let status = output.status.code();
691    let stdout = String::from_utf8_lossy(&output.stdout)
692        .trim_end()
693        .to_string();
694    let stderr = String::from_utf8_lossy(&output.stderr)
695        .trim_end()
696        .to_string();
697    axum::extract::Json(CliCommandResponse {
698        ok: output.status.success(),
699        message: if output.status.success() {
700            "command completed".into()
701        } else {
702            format!("command exited with {}", status.unwrap_or(-1))
703        },
704        status,
705        stdout,
706        stderr,
707    })
708}
709
710fn webview_cli_args(command: &str) -> Result<Vec<String>, String> {
711    let command = command.trim().trim_start_matches('/').trim();
712    if command.is_empty() {
713        return Err("empty command".into());
714    }
715    let mut args = split_webview_command(command)?;
716    if args.is_empty() {
717        return Err("empty command".into());
718    }
719    match args[0].as_str() {
720        "models" => args[0] = "model".into(),
721        "routing" => args[0] = "route".into(),
722        _ => {}
723    }
724    if args[0] == "model" && args.len() == 1 {
725        args.push("--list".into());
726    }
727    if args[0] == "run" && args.len() > 2 {
728        let task = args[1..].join(" ");
729        args.truncate(1);
730        args.push(task);
731    }
732    if args[0] == "plan" && args.len() > 2 {
733        let task = args[1..].join(" ");
734        args.truncate(1);
735        args.push(task);
736    }
737    if args[0] == "swarm" && args.len() > 2 {
738        let task = args[1..].join(" ");
739        args.truncate(1);
740        args.push(task);
741    }
742    Ok(args)
743}
744
745fn blocked_webview_cli_command(args: &[String]) -> Option<String> {
746    let first = args.first().map(String::as_str)?;
747    if matches!(first, "console" | "tui" | "chat" | "daemon") {
748        return Some(format!(
749            "`/{first}` opens an interactive process; launch it from a terminal instead."
750        ));
751    }
752    if first == "gateway" && args.get(1).map(String::as_str) == Some("start") {
753        return Some("`/gateway start` starts a daemon; launch it from a terminal instead.".into());
754    }
755    None
756}
757
758fn split_webview_command(input: &str) -> Result<Vec<String>, String> {
759    let mut args = Vec::new();
760    let mut current = String::new();
761    let mut chars = input.chars().peekable();
762    let mut quote: Option<char> = None;
763    while let Some(ch) = chars.next() {
764        match (quote, ch) {
765            (Some(q), c) if c == q => quote = None,
766            (Some(_), '\\') => {
767                if let Some(next) = chars.next() {
768                    current.push(next);
769                }
770            }
771            (Some(_), c) => current.push(c),
772            (None, '\'' | '"') => quote = Some(ch),
773            (None, c) if c.is_whitespace() => {
774                if !current.is_empty() {
775                    args.push(std::mem::take(&mut current));
776                }
777            }
778            (None, c) => current.push(c),
779        }
780    }
781    if let Some(q) = quote {
782        return Err(format!("unterminated {q} quote"));
783    }
784    if !current.is_empty() {
785        args.push(current);
786    }
787    Ok(args)
788}
789
790async fn get_commands(
791    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
792) -> axum::extract::Json<CommandsResponse> {
793    let commands = commands_for_state(&state)
794        .into_iter()
795        .map(|cmd| CommandView {
796            name: format!("/{}", cmd.name),
797            description: cmd.description,
798            usage: cmd.body,
799            source: match cmd.source {
800                crate::commands::SlashCommandSource::Builtin => "builtin".into(),
801                crate::commands::SlashCommandSource::Project(path) => {
802                    format!("project:{}", path.display())
803                }
804                crate::commands::SlashCommandSource::User(path) => {
805                    format!("user:{}", path.display())
806                }
807                crate::commands::SlashCommandSource::Skill(name) => format!("skill:{}", name),
808                crate::commands::SlashCommandSource::Plugin(name) => format!("plugin:{}", name),
809            },
810        })
811        .collect();
812    axum::extract::Json(CommandsResponse {
813        ok: true,
814        message: "commands loaded".into(),
815        commands,
816    })
817}
818
819fn commands_for_state(state: &AppState) -> Vec<crate::commands::SlashCommand> {
820    let project_root = std::env::current_dir().unwrap_or_default();
821    let config_dir = state
822        .config
823        .as_ref()
824        .and_then(|cfg| cfg.read().ok().map(|cfg| cfg.config_dir.clone()))
825        .unwrap_or_else(|| {
826            dirs::config_dir()
827                .unwrap_or_else(|| std::path::PathBuf::from("."))
828                .join("sparrow")
829        });
830    crate::commands::all_commands(&project_root, &config_dir, state.skills.as_deref())
831}
832
833async fn get_memory(
834    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
835) -> axum::extract::Json<MemoryResponse> {
836    let Some(memory) = &state.memory else {
837        return axum::extract::Json(MemoryResponse {
838            ok: false,
839            message: "memory unavailable".into(),
840            stats: None,
841            docs: Vec::new(),
842            facts: Vec::new(),
843        });
844    };
845    let stats = memory.memory_stats();
846    let docs = [MemoryDocKind::Memory, MemoryDocKind::User]
847        .into_iter()
848        .filter_map(|kind| {
849            memory.memory_doc(kind).map(|doc| MemoryDocView {
850                kind: kind.as_str().to_string(),
851                chars: doc.content.chars().count(),
852                limit: kind.limit(),
853                updated_at: doc.updated_at,
854                content: doc.content,
855            })
856        })
857        .collect();
858    let facts = memory
859        .all_facts()
860        .into_iter()
861        .take(25)
862        .map(|fact| MemoryFactView {
863            id: fact.id,
864            key: fact.key,
865            value: fact.value,
866            updated_at: fact.updated_at,
867        })
868        .collect();
869    axum::extract::Json(MemoryResponse {
870        ok: true,
871        message: "loaded".into(),
872        stats: Some(stats),
873        docs,
874        facts,
875    })
876}
877
878async fn get_plugins(
879    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
880) -> axum::extract::Json<PluginsResponse> {
881    let config_dir = state
882        .config
883        .as_ref()
884        .and_then(|cfg| cfg.read().ok().map(|cfg| cfg.config_dir.clone()))
885        .unwrap_or_else(|| {
886            dirs::config_dir()
887                .unwrap_or_else(|| std::path::PathBuf::from("."))
888                .join("sparrow")
889        });
890    let dirs = [
891        std::env::current_dir()
892            .unwrap_or_default()
893            .join(".sparrow")
894            .join("plugins"),
895        config_dir.join("plugins"),
896    ];
897    let mut plugins = Vec::new();
898    for dir in dirs {
899        let registry = crate::capabilities::plugin::PluginRegistry::new(dir);
900        for plugin in registry.scan() {
901            let audit = registry.audit(&plugin);
902            plugins.push(PluginView {
903                name: plugin.manifest.name,
904                version: plugin.manifest.version,
905                description: plugin.manifest.description,
906                commands: plugin.manifest.commands.len(),
907                skills: plugin.manifest.skills.len(),
908                hooks: plugin.manifest.hooks.len(),
909                allowed: audit.allowed,
910                warnings: audit.warnings,
911            });
912        }
913    }
914    axum::extract::Json(PluginsResponse {
915        ok: true,
916        message: "loaded".into(),
917        plugins,
918    })
919}
920
921async fn get_tools() -> axum::extract::Json<ToolsResponse> {
922    let tools = crate::tools::known_tool_metadata(None);
923    let manifests = tools
924        .iter()
925        .cloned()
926        .map(|meta| crate::tools::ToolManifest::from_metadata("", meta))
927        .collect();
928    axum::extract::Json(ToolsResponse {
929        ok: true,
930        message: "loaded".into(),
931        toolsets: crate::tools::TOOLSETS
932            .iter()
933            .map(|set| set.to_string())
934            .collect(),
935        tools,
936        manifests,
937    })
938}
939
940async fn list_models(
941    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
942) -> axum::extract::Json<serde_json::Value> {
943    use crate::config::providers::provider_registry;
944    let providers = provider_registry();
945    let out: Vec<serde_json::Value> = providers
946        .iter()
947        .map(|p| {
948            // Curated models from the static registry.
949            let mut models: Vec<serde_json::Value> = p
950                .models
951                .iter()
952                .map(|m| {
953                    serde_json::json!({
954                        "name": m.name,
955                        "label": m.label,
956                        "tags": m.tags,
957                        "context_window": m.context_window,
958                        "cost_in": m.cost_input_per_mtok,
959                        "cost_out": m.cost_output_per_mtok,
960                        "recommended": m.recommended,
961                        "source": "registry",
962                    })
963                })
964                .collect();
965            // Merge live-discovered models from the SQLite cache (e.g. the 92
966            // NVIDIA models) so the picker shows everything, not just curated.
967            // For each discovered model, infer per-model caps from the name so
968            // the WebView context-window meter adapts (DeepSeek V4 Pro = 1M,
969            // not the previous hard-coded 128k default).
970            if let Some(mem) = &state.memory {
971                let curated: std::collections::HashSet<String> =
972                    p.models.iter().map(|m| m.name.clone()).collect();
973                for name in mem.get_discovered_models(&p.id) {
974                    if !curated.contains(&name) {
975                        let caps = crate::config::providers::model_caps(&p.id, &name);
976                        models.push(serde_json::json!({
977                            "name": name,
978                            "label": name,
979                            "tags": [],
980                            "context_window": caps.context_window,
981                            "max_output": caps.max_output,
982                            "cost_in": caps.cost_input_per_mtok,
983                            "cost_out": caps.cost_output_per_mtok,
984                            "recommended": false,
985                            "source": "discovered",
986                        }));
987                    }
988                }
989            }
990            serde_json::json!({
991                "id": p.id,
992                "label": p.label,
993                "tags": p.tags,
994                "model_count": models.len(),
995                "models": models,
996            })
997        })
998        .collect();
999    axum::extract::Json(serde_json::json!({ "ok": true, "providers": out }))
1000}
1001
1002async fn stop_run(
1003    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1004) -> axum::extract::Json<RunResponse> {
1005    match &state.command_tx {
1006        Some(tx) if tx.send("__stop__".to_string()).is_ok() => axum::extract::Json(RunResponse {
1007            ok: true,
1008            message: "stop requested".into(),
1009        }),
1010        _ => axum::extract::Json(RunResponse {
1011            ok: false,
1012            message: "console command channel unavailable".into(),
1013        }),
1014    }
1015}
1016
1017async fn reset_conversation(
1018    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1019) -> axum::extract::Json<RunResponse> {
1020    // Send a sentinel string through the command channel; main.rs interprets
1021    // __reset_conversation__ as a request to drain conv_history.
1022    match &state.command_tx {
1023        Some(tx) if tx.send("__reset_conversation__".to_string()).is_ok() => {
1024            axum::extract::Json(RunResponse {
1025                ok: true,
1026                message: "conversation cleared".into(),
1027            })
1028        }
1029        _ => axum::extract::Json(RunResponse {
1030            ok: false,
1031            message: "console command channel unavailable".into(),
1032        }),
1033    }
1034}
1035
1036async fn get_status() -> axum::extract::Json<serde_json::Value> {
1037    use crate::config::providers::provider_registry;
1038    let providers = provider_registry();
1039    axum::extract::Json(serde_json::json!({
1040        "ok": true,
1041        "version": env!("CARGO_PKG_VERSION"),
1042        "providers_total": providers.len(),
1043        "workdir": std::env::current_dir().ok().map(|p| p.to_string_lossy().to_string()),
1044    }))
1045}
1046
1047/// Check for Sparrow updates — called by the WebView frontend on load.
1048async fn check_update_api() -> axum::extract::Json<serde_json::Value> {
1049    let current = env!("CARGO_PKG_VERSION");
1050    let update = tokio::task::spawn_blocking(crate::update::check_update)
1051        .await
1052        .ok()
1053        .flatten();
1054    match update {
1055        Some(info) => axum::extract::Json(serde_json::json!({
1056            "update_available": true,
1057            "current": info.current,
1058            "latest": info.latest,
1059            "download_url": info.download_url,
1060            "crate_url": info.crate_url,
1061            "release_url": info.release_url,
1062            "install_cmd": info.install_cmd,
1063        })),
1064        None => axum::extract::Json(serde_json::json!({
1065            "update_available": false,
1066            "current": current,
1067            "latest": current,
1068        })),
1069    }
1070}
1071
1072#[derive(serde::Deserialize)]
1073struct FileQuery {
1074    path: String,
1075}
1076
1077async fn read_file(
1078    axum::extract::Query(q): axum::extract::Query<FileQuery>,
1079) -> axum::response::Response {
1080    use axum::response::IntoResponse;
1081    // Sandbox: only allow paths inside cwd.
1082    let cwd = match std::env::current_dir() {
1083        Ok(d) => d,
1084        Err(_) => {
1085            return (
1086                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1087                "cwd unavailable",
1088            )
1089                .into_response();
1090        }
1091    };
1092    // Canonicalize cwd too so UNC prefixes match on Windows.
1093    let cwd_canon = cwd.canonicalize().unwrap_or(cwd.clone());
1094    let requested = std::path::Path::new(&q.path);
1095    let canonical = match cwd.join(requested).canonicalize() {
1096        Ok(p) => p,
1097        Err(_) => return (axum::http::StatusCode::NOT_FOUND, "file not found").into_response(),
1098    };
1099    if !canonical.starts_with(&cwd_canon) {
1100        return (axum::http::StatusCode::FORBIDDEN, "path outside workdir").into_response();
1101    }
1102    match std::fs::read_to_string(&canonical) {
1103        Ok(content) => {
1104            let ext = canonical.extension().and_then(|e| e.to_str()).unwrap_or("");
1105            let lang = match ext {
1106                "rs" => "rust",
1107                "js" | "ts" | "jsx" | "tsx" => "javascript",
1108                "py" => "python",
1109                "toml" => "toml",
1110                "md" => "markdown",
1111                "html" => "html",
1112                "css" => "css",
1113                "json" => "json",
1114                _ => "text",
1115            };
1116            axum::extract::Json(serde_json::json!({
1117                "ok": true, "path": q.path, "lang": lang,
1118                "lines": content.lines().count(),
1119                "content": content,
1120            }))
1121            .into_response()
1122        }
1123        Err(_) => (axum::http::StatusCode::NOT_FOUND, "cannot read file").into_response(),
1124    }
1125}
1126
1127async fn resolve_approval(
1128    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1129    axum::extract::Json(req): axum::extract::Json<ApprovalResponseRequest>,
1130) -> axum::extract::Json<RunResponse> {
1131    let Some(approvals) = &state.approvals else {
1132        return axum::extract::Json(RunResponse {
1133            ok: false,
1134            message: "approval channel unavailable".into(),
1135        });
1136    };
1137    let decision = match req.decision.trim().to_lowercase().as_str() {
1138        // The scope (once/session/always) is enforced client-side: the frontend
1139        // remembers session-approved tool names and skips the next prompt; an
1140        // "always" decision should be paired with a separate POST /permissions
1141        // call to persist the rule. All three map to Allow at the engine level.
1142        "allow" | "approve" | "approved" | "allow_once" | "allow_session" | "allow_always" => {
1143            Decision::Allow
1144        }
1145        "deny" | "reject" | "rejected" => Decision::Deny,
1146        _ => {
1147            return axum::extract::Json(RunResponse {
1148                ok: false,
1149                message: "decision must be approve/allow_once/allow_session/allow_always/deny"
1150                    .into(),
1151            });
1152        }
1153    };
1154    if approvals.resolve(req.id.trim(), decision).await {
1155        axum::extract::Json(RunResponse {
1156            ok: true,
1157            message: "approval resolved".into(),
1158        })
1159    } else {
1160        axum::extract::Json(RunResponse {
1161            ok: false,
1162            message: "approval not found or already resolved".into(),
1163        })
1164    }
1165}
1166
1167async fn get_config(
1168    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1169) -> axum::extract::Json<ConfigResponse> {
1170    let Some(shared) = &state.config else {
1171        return axum::extract::Json(ConfigResponse {
1172            ok: false,
1173            message: "config unavailable".into(),
1174            budget: None,
1175            workdir: None,
1176            skills_count: None,
1177            autonomy: String::new(),
1178            sandbox: String::new(),
1179            providers: vec![],
1180        });
1181    };
1182
1183    let cfg = shared.read().expect("config lock poisoned").clone();
1184    let auth = crate::auth::store::ChainedAuthStore::new(cfg.config_dir.clone());
1185    let mut providers = crate::config::providers::onboarding_providers()
1186        .into_iter()
1187        .map(|def| {
1188            let configured = cfg.providers.get(&def.id);
1189            let api_key_env = configured
1190                .and_then(|p| {
1191                    p.api_key_env
1192                        .as_ref()
1193                        .filter(|value| !looks_like_api_key(value))
1194                        .cloned()
1195                })
1196                .or_else(|| def.api_key_env.clone());
1197            let has_credential = auth.get(&def.id).is_some()
1198                || configured
1199                    .and_then(|p| p.api_key_env.as_ref())
1200                    .map(|value| {
1201                        looks_like_api_key(value)
1202                            || std::env::var(value)
1203                                .map(|env_value| !env_value.is_empty())
1204                                .unwrap_or(false)
1205                    })
1206                    .unwrap_or(false)
1207                || api_key_env
1208                    .as_ref()
1209                    .map(|value| {
1210                        std::env::var(value)
1211                            .map(|env_value| !env_value.is_empty())
1212                            .unwrap_or(false)
1213                    })
1214                    .unwrap_or(false);
1215
1216            // Merge configured + curated + discovered into a single sorted
1217            // unique list so the config panel's "X models" count survives
1218            // across sessions (was only counting the static registry, hiding
1219            // the 60+ models the user had just scanned).
1220            let mut models: Vec<String> = configured
1221                .map(|p| {
1222                    if p.models.is_empty() {
1223                        def.models.iter().map(|m| m.name.clone()).collect()
1224                    } else {
1225                        p.models.clone()
1226                    }
1227                })
1228                .unwrap_or_else(|| def.models.iter().map(|m| m.name.clone()).collect());
1229            if let Some(mem) = &state.memory {
1230                let known: std::collections::HashSet<String> = models.iter().cloned().collect();
1231                for name in mem.get_discovered_models(&def.id) {
1232                    if !known.contains(&name) {
1233                        models.push(name);
1234                    }
1235                }
1236            }
1237            ProviderView {
1238                name: def.id,
1239                label: def.label,
1240                adapter: configured.map(|p| p.adapter.clone()).unwrap_or(def.adapter),
1241                base_url: configured
1242                    .and_then(|p| p.base_url.clone())
1243                    .or(Some(def.base_url)),
1244                models,
1245                tags: def.tags,
1246                notes: def.notes,
1247                api_key_env,
1248                has_credential,
1249                configured: configured.is_some(),
1250            }
1251        })
1252        .collect::<Vec<_>>();
1253
1254    for (name, p) in &cfg.providers {
1255        if providers.iter().any(|view| &view.name == name) {
1256            continue;
1257        }
1258        let api_key_env = p
1259            .api_key_env
1260            .as_ref()
1261            .filter(|value| !looks_like_api_key(value))
1262            .cloned();
1263        providers.push(ProviderView {
1264            name: name.clone(),
1265            label: name.clone(),
1266            adapter: p.adapter.clone(),
1267            base_url: p.base_url.clone(),
1268            models: p.models.clone(),
1269            tags: vec!["custom".into()],
1270            notes: "Custom configured provider.".into(),
1271            api_key_env: api_key_env.clone(),
1272            has_credential: auth.get(name).is_some()
1273                || p.api_key_env
1274                    .as_ref()
1275                    .map(|value| {
1276                        looks_like_api_key(value)
1277                            || std::env::var(value)
1278                                .map(|env_value| !env_value.is_empty())
1279                                .unwrap_or(false)
1280                    })
1281                    .unwrap_or(false),
1282            configured: true,
1283        });
1284    }
1285    providers.sort_by(|a, b| a.name.cmp(&b.name));
1286
1287    axum::extract::Json(ConfigResponse {
1288        ok: true,
1289        message: "loaded".into(),
1290        autonomy: format!("{:?}", cfg.defaults.autonomy),
1291        sandbox: cfg.defaults.sandbox,
1292        providers,
1293        budget: Some(BudgetView {
1294            session_usd: cfg.budget.session_usd,
1295            daily_usd: cfg.budget.daily_usd,
1296        }),
1297        workdir: std::env::current_dir()
1298            .ok()
1299            .map(|p| p.to_string_lossy().to_string()),
1300        skills_count: None,
1301    })
1302}
1303
1304async fn save_provider(
1305    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1306    axum::extract::Json(req): axum::extract::Json<ProviderRequest>,
1307) -> axum::extract::Json<RunResponse> {
1308    let Some(shared) = &state.config else {
1309        return axum::extract::Json(RunResponse {
1310            ok: false,
1311            message: "config unavailable".into(),
1312        });
1313    };
1314
1315    let mut cfg = shared.write().expect("config lock poisoned");
1316    if let Some(level) = parse_autonomy(req.autonomy.as_deref()) {
1317        cfg.defaults.autonomy = level;
1318    }
1319    if let Some(sandbox) = req
1320        .sandbox
1321        .as_ref()
1322        .map(|s| s.trim().to_string())
1323        .filter(|s| !s.is_empty())
1324    {
1325        cfg.defaults.sandbox = sandbox;
1326    }
1327
1328    let name = req.name.trim().to_lowercase();
1329    if name.is_empty() {
1330        let saved = cfg.clone();
1331        let store = FsConfigStore::new(saved.config_dir.clone());
1332        if let Err(err) = store.save(&saved) {
1333            return axum::extract::Json(RunResponse {
1334                ok: false,
1335                message: format!("config save failed: {}", err),
1336            });
1337        }
1338        return axum::extract::Json(RunResponse {
1339            ok: true,
1340            message: "runtime preferences saved".into(),
1341        });
1342    }
1343
1344    let raw_api_key_env = req
1345        .api_key_env
1346        .as_ref()
1347        .map(|s| s.trim().to_string())
1348        .filter(|s| !s.is_empty());
1349    let api_key_env = raw_api_key_env
1350        .as_ref()
1351        .filter(|value| !looks_like_api_key(value))
1352        .cloned();
1353    let api_key_from_env_field = raw_api_key_env
1354        .as_ref()
1355        .filter(|value| looks_like_api_key(value))
1356        .cloned();
1357
1358    cfg.providers.insert(
1359        name.clone(),
1360        ProviderConfig {
1361            adapter: req.adapter.trim().to_string(),
1362            base_url: req
1363                .base_url
1364                .as_ref()
1365                .map(|s| s.trim().to_string())
1366                .filter(|s| !s.is_empty()),
1367            models: req
1368                .models
1369                .into_iter()
1370                .map(|m| m.trim().to_string())
1371                .filter(|m| !m.is_empty())
1372                .collect(),
1373            api_key_env,
1374        },
1375    );
1376
1377    let saved = cfg.clone();
1378    let store = FsConfigStore::new(saved.config_dir.clone());
1379    if let Err(err) = store.save(&saved) {
1380        return axum::extract::Json(RunResponse {
1381            ok: false,
1382            message: format!("config save failed: {}", err),
1383        });
1384    }
1385
1386    if let Some(key) = req
1387        .api_key
1388        .map(|k| k.trim().to_string())
1389        .filter(|k| !k.is_empty())
1390        .or(api_key_from_env_field)
1391    {
1392        let auth = crate::auth::store::ChainedAuthStore::new(saved.config_dir);
1393        if let Err(err) = auth.set(&name, Credential::api_key(key)) {
1394            return axum::extract::Json(RunResponse {
1395                ok: false,
1396                message: format!("credential save failed: {}", err),
1397            });
1398        }
1399    }
1400
1401    axum::extract::Json(RunResponse {
1402        ok: true,
1403        message: format!("provider '{}' saved", name),
1404    })
1405}
1406
1407/// Hard cap for WebView attachments (10 MB).
1408pub const MAX_ATTACHMENT_BYTES: usize = 10 * 1024 * 1024;
1409
1410/// Where attachments are stored, relative to the current working directory.
1411pub fn attachments_dir() -> std::path::PathBuf {
1412    std::env::current_dir()
1413        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1414        .join(".sparrow")
1415        .join("attachments")
1416}
1417
1418#[derive(serde::Serialize)]
1419pub struct AttachmentMetadata {
1420    pub name: String,
1421    pub path: String,
1422    pub size: u64,
1423    pub mime: String,
1424    pub kind: &'static str,
1425}
1426
1427pub fn classify_attachment(mime: &str, ext: &str) -> &'static str {
1428    let ext = ext.to_ascii_lowercase();
1429    if mime.starts_with("image/")
1430        || matches!(
1431            ext.as_str(),
1432            "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
1433        )
1434    {
1435        "image"
1436    } else if mime.starts_with("audio/")
1437        || matches!(ext.as_str(), "mp3" | "wav" | "m4a" | "ogg" | "flac")
1438    {
1439        "audio"
1440    } else if mime == "application/pdf" || ext == "pdf" {
1441        "pdf"
1442    } else if mime.starts_with("text/")
1443        || matches!(
1444            ext.as_str(),
1445            "md" | "txt" | "csv" | "json" | "toml" | "yml" | "yaml"
1446        )
1447    {
1448        "text"
1449    } else {
1450        "file"
1451    }
1452}
1453
1454async fn upload_attachment(
1455    mut multipart: axum::extract::Multipart,
1456) -> axum::extract::Json<serde_json::Value> {
1457    let dir = attachments_dir();
1458    if let Err(e) = std::fs::create_dir_all(&dir) {
1459        return axum::extract::Json(serde_json::json!({
1460            "ok": false,
1461            "message": format!("could not create attachments dir: {}", e),
1462        }));
1463    }
1464    let mut accepted: Vec<AttachmentMetadata> = Vec::new();
1465    let mut rejected: Vec<serde_json::Value> = Vec::new();
1466    while let Ok(Some(field)) = multipart.next_field().await {
1467        let original = field
1468            .file_name()
1469            .map(|s| s.to_string())
1470            .unwrap_or_else(|| "upload.bin".into());
1471        let content_type = field
1472            .content_type()
1473            .unwrap_or("application/octet-stream")
1474            .to_string();
1475        let data = match field.bytes().await {
1476            Ok(b) => b,
1477            Err(e) => {
1478                rejected.push(
1479                    serde_json::json!({"name": original, "reason": format!("read error: {}", e)}),
1480                );
1481                continue;
1482            }
1483        };
1484        if data.len() > MAX_ATTACHMENT_BYTES {
1485            rejected.push(serde_json::json!({
1486                "name": original,
1487                "reason": format!("too large: {} bytes > limit {}", data.len(), MAX_ATTACHMENT_BYTES),
1488            }));
1489            continue;
1490        }
1491        // Sanitize filename: strip directory components.
1492        let safe = std::path::Path::new(&original)
1493            .file_name()
1494            .map(|s| s.to_string_lossy().to_string())
1495            .unwrap_or_else(|| "upload.bin".into());
1496        let dest = dir.join(&safe);
1497        if let Err(e) = std::fs::write(&dest, &data) {
1498            rejected
1499                .push(serde_json::json!({"name": safe, "reason": format!("write error: {}", e)}));
1500            continue;
1501        }
1502        let ext = std::path::Path::new(&safe)
1503            .extension()
1504            .map(|s| s.to_string_lossy().to_string())
1505            .unwrap_or_default();
1506        let kind = classify_attachment(&content_type, &ext);
1507        accepted.push(AttachmentMetadata {
1508            name: safe.clone(),
1509            path: dest.to_string_lossy().to_string(),
1510            size: data.len() as u64,
1511            mime: content_type,
1512            kind,
1513        });
1514    }
1515
1516    axum::extract::Json(serde_json::json!({
1517        "ok": !accepted.is_empty(),
1518        "accepted": accepted,
1519        "rejected": rejected,
1520        "limit_bytes": MAX_ATTACHMENT_BYTES,
1521    }))
1522}
1523
1524/// The dedicated directory where the agent is told to place generated
1525/// deliverables (see the "Where to put what you create" prompt section).
1526pub fn artifacts_dir() -> std::path::PathBuf {
1527    std::env::current_dir()
1528        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1529        .join("artifacts")
1530}
1531
1532/// Collect files (one level deep) from `dir` into `items`, tagging each with
1533/// `source` so the WebView can group uploads vs generated deliverables.
1534fn collect_artifact_files(dir: &std::path::Path, items: &mut Vec<serde_json::Value>, source: &str) {
1535    if let Ok(entries) = std::fs::read_dir(dir) {
1536        for entry in entries.flatten() {
1537            let path = entry.path();
1538            if !path.is_file() {
1539                continue;
1540            }
1541            let name = path
1542                .file_name()
1543                .map(|s| s.to_string_lossy().to_string())
1544                .unwrap_or_default();
1545            let ext = path
1546                .extension()
1547                .map(|s| s.to_string_lossy().to_string())
1548                .unwrap_or_default();
1549            let mime = mime_guess::from_path(&path)
1550                .first_or_octet_stream()
1551                .to_string();
1552            let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
1553            let kind = classify_attachment(&mime, &ext);
1554            items.push(serde_json::json!({
1555                "name": name,
1556                "path": path.to_string_lossy().to_string(),
1557                "size": size,
1558                "mime": mime,
1559                "kind": kind,
1560                "source": source,
1561            }));
1562        }
1563    }
1564}
1565
1566async fn list_artifacts() -> axum::extract::Json<serde_json::Value> {
1567    let attachments = attachments_dir();
1568    let generated = artifacts_dir();
1569    let mut items: Vec<serde_json::Value> = Vec::new();
1570    // Generated deliverables first (the dedicated ./artifacts/ dir), then uploads.
1571    collect_artifact_files(&generated, &mut items, "generated");
1572    collect_artifact_files(&attachments, &mut items, "upload");
1573    axum::extract::Json(serde_json::json!({
1574        "ok": true,
1575        "items": items,
1576        "dir": attachments.to_string_lossy().to_string(),
1577        "artifacts_dir": generated.to_string_lossy().to_string(),
1578    }))
1579}
1580
1581/// `GET /skills` — return the local skill library so the drawer panel can list
1582/// names + descriptions. Reads from the same dir the runtime uses.
1583async fn list_skills() -> axum::extract::Json<serde_json::Value> {
1584    use crate::capabilities::FsSkillLibrary;
1585    let skills_dir = dirs::config_dir()
1586        .unwrap_or_else(|| std::path::PathBuf::from("."))
1587        .join("sparrow")
1588        .join("skills");
1589    let lib = FsSkillLibrary::new(skills_dir.clone());
1590    let scanned = lib.scan();
1591    let skills: Vec<serde_json::Value> = scanned
1592        .into_iter()
1593        .map(|s| {
1594            serde_json::json!({
1595                "name": s.name,
1596                "description": s.description,
1597                "uses": s.usage_count,
1598                "score": s.score,
1599                "auto_generated": s.auto_generated,
1600            })
1601        })
1602        .collect();
1603    axum::extract::Json(serde_json::json!({
1604        "ok": true,
1605        "skills": skills,
1606        "dir": skills_dir.to_string_lossy().to_string(),
1607    }))
1608}
1609
1610#[derive(serde::Deserialize)]
1611struct CreateAgentReq {
1612    name: String,
1613    role: Option<String>,
1614    description: Option<String>,
1615    model: Option<String>,
1616    color_key: Option<String>,
1617    soul: Option<String>,     // raw markdown for .soul.toml personality
1618    agent_md: Option<String>, // raw markdown for .agent.md long-form
1619    allowed_tools: Option<Vec<String>>,
1620}
1621
1622/// `POST /agents` — create a persistent agent on disk under the user's local
1623/// `./agents/` dir (workdir). Writes `<name>.soul.toml` (TOML config) and
1624/// optionally `<name>.agent.md` (long-form persona / instructions).
1625async fn create_agent(
1626    axum::extract::Json(req): axum::extract::Json<CreateAgentReq>,
1627) -> axum::extract::Json<serde_json::Value> {
1628    let name = req.name.trim();
1629    if name.is_empty()
1630        || name.contains(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
1631    {
1632        return axum::extract::Json(serde_json::json!({
1633            "ok": false,
1634            "message": "agent name must be ascii alphanumeric/_/- only",
1635        }));
1636    }
1637    let dir = std::env::current_dir()
1638        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1639        .join("agents");
1640    if let Err(e) = std::fs::create_dir_all(&dir) {
1641        return axum::extract::Json(serde_json::json!({
1642            "ok": false,
1643            "message": format!("could not create agents dir: {e}"),
1644        }));
1645    }
1646    let role = req.role.as_deref().unwrap_or("custom agent");
1647    let description = req.description.as_deref().unwrap_or("");
1648    let color_key = req.color_key.as_deref().unwrap_or("steel");
1649    let model = req.model.as_deref().unwrap_or("");
1650    let allowed_tools = req.allowed_tools.unwrap_or_default();
1651    let soul_path = dir.join(format!("{name}.soul.toml"));
1652    let soul = req.soul.unwrap_or_else(|| {
1653        let tools_block = if allowed_tools.is_empty() {
1654            String::new()
1655        } else {
1656            format!(
1657                "allowed_tools = [{}]\n",
1658                allowed_tools
1659                    .iter()
1660                    .map(|t| format!("\"{}\"", t.replace('"', "\\\"")))
1661                    .collect::<Vec<_>>()
1662                    .join(", ")
1663            )
1664        };
1665        format!(
1666            "# Sparrow persistent agent\n\
1667             name = \"{name}\"\n\
1668             role = \"{role}\"\n\
1669             description = \"\"\"{description}\"\"\"\n\
1670             color_key = \"{color_key}\"\n\
1671             {model_line}\
1672             {tools_block}\n\
1673             [personality]\n\
1674             tone = \"concise, competent, direct\"\n",
1675            name = name,
1676            role = role.replace('"', "\\\""),
1677            description = description.replace('"', "\\\""),
1678            color_key = color_key,
1679            model_line = if model.is_empty() {
1680                String::new()
1681            } else {
1682                format!("model = \"{}\"\n", model.replace('"', "\\\""))
1683            },
1684            tools_block = tools_block,
1685        )
1686    });
1687    if let Err(e) = std::fs::write(&soul_path, soul) {
1688        return axum::extract::Json(serde_json::json!({
1689            "ok": false,
1690            "message": format!("could not write soul file: {e}"),
1691        }));
1692    }
1693    if let Some(md) = req.agent_md {
1694        if !md.trim().is_empty() {
1695            let md_path = dir.join(format!("{name}.agent.md"));
1696            if let Err(e) = std::fs::write(&md_path, md) {
1697                return axum::extract::Json(serde_json::json!({
1698                    "ok": false,
1699                    "message": format!("could not write agent.md: {e}"),
1700                }));
1701            }
1702        }
1703    }
1704    axum::extract::Json(serde_json::json!({
1705        "ok": true,
1706        "name": name,
1707        "soul_path": soul_path.to_string_lossy().to_string(),
1708        "message": "agent created",
1709    }))
1710}
1711
1712/// `DELETE /agents/:name` — remove a persistent agent's local files.
1713async fn delete_agent(
1714    axum::extract::Path(name): axum::extract::Path<String>,
1715) -> axum::extract::Json<serde_json::Value> {
1716    if name.is_empty()
1717        || name.contains(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
1718    {
1719        return axum::extract::Json(serde_json::json!({
1720            "ok": false,
1721            "message": "invalid agent name",
1722        }));
1723    }
1724    let dir = std::env::current_dir()
1725        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1726        .join("agents");
1727    let soul = dir.join(format!("{name}.soul.toml"));
1728    let md = dir.join(format!("{name}.agent.md"));
1729    let mut removed = 0u32;
1730    if soul.exists() {
1731        let _ = std::fs::remove_file(&soul);
1732        removed += 1;
1733    }
1734    if md.exists() {
1735        let _ = std::fs::remove_file(&md);
1736        removed += 1;
1737    }
1738    axum::extract::Json(serde_json::json!({
1739        "ok": removed > 0,
1740        "removed": removed,
1741        "message": if removed > 0 { "deleted" } else { "not found" },
1742    }))
1743}
1744
1745/// `GET /agents` — return every installed agent so the WebView swarm row and
1746/// the composer's `@<name>` picker can render the real list (Sprint 1, item
1747/// dynamic WebView swarm row. Status is "idle" by default; the runtime updates a
1748/// shared `Arc<Mutex<AgentRuntimeState>>` later — for v0.3.0 we ship the
1749/// listing as a cold view backed by `FsAgentStore::list()`.
1750async fn list_agents() -> axum::extract::Json<serde_json::Value> {
1751    use crate::agent::{AgentStore, FsAgentStore};
1752
1753    let agents_dir = dirs::config_dir()
1754        .unwrap_or_else(|| std::path::PathBuf::from("."))
1755        .join("sparrow")
1756        .join("agents");
1757
1758    // Collect souls from user config dir + local repo `agents/` dir + `.sparrow/agents/`.
1759    let extra_dirs: Vec<std::path::PathBuf> = [
1760        std::env::current_dir().ok().map(|d| d.join("agents")),
1761        std::env::current_dir()
1762            .ok()
1763            .map(|d| d.join(".sparrow").join("agents")),
1764    ]
1765    .into_iter()
1766    .flatten()
1767    .filter(|p| p.is_dir())
1768    .collect();
1769
1770    let store = FsAgentStore::new(agents_dir.clone());
1771    let mut souls = store.list();
1772    let mut seen: std::collections::HashSet<String> =
1773        souls.iter().map(|s| s.name.clone()).collect();
1774    for dir in &extra_dirs {
1775        let extra = FsAgentStore::new(dir.clone()).list();
1776        for s in extra {
1777            if seen.insert(s.name.clone()) {
1778                souls.push(s);
1779            }
1780        }
1781    }
1782
1783    let items: Vec<serde_json::Value> = souls
1784        .into_iter()
1785        .map(|s| {
1786            // Pick a colour key the WebView already knows about; falls back to
1787            // the canonical triad if the agent uses one of those role names.
1788            let color_key = match s.role.to_lowercase().as_str() {
1789                "planner" => "planner",
1790                "coder" => "coder",
1791                "verifier" => "verifier",
1792                _ => s
1793                    .color
1794                    .as_deref()
1795                    .map(classify_agent_color)
1796                    .unwrap_or("steel"),
1797            };
1798            serde_json::json!({
1799                "name": s.name,
1800                "role": s.role,
1801                "description": s.description,
1802                "status": "idle",
1803                "msg": "",
1804                "color_key": color_key,
1805            })
1806        })
1807        .collect();
1808
1809    axum::extract::Json(serde_json::json!({
1810        "ok": true,
1811        "dir": agents_dir.to_string_lossy(),
1812        "agents": items,
1813    }))
1814}
1815
1816/// Maps the optional `color` field of a `Soul` to one of the known WebView
1817/// theme tokens. Unknown values fall back to `steel`.
1818pub fn classify_agent_color(raw: &str) -> &'static str {
1819    match raw.trim().to_lowercase().as_str() {
1820        "planner" | "blue" => "planner",
1821        "coder" | "teal" | "agent" => "coder",
1822        "verifier" | "sand" => "verifier",
1823        "gold" | "yellow" => "gold",
1824        "coral" | "red" => "coral",
1825        _ => "steel",
1826    }
1827}
1828
1829#[derive(serde::Deserialize)]
1830struct LoadSessionRequest {
1831    id: String,
1832}
1833
1834async fn load_session(
1835    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1836    axum::extract::Json(req): axum::extract::Json<LoadSessionRequest>,
1837) -> axum::extract::Json<RunResponse> {
1838    let id = req.id.trim();
1839    if id.is_empty() {
1840        return axum::extract::Json(RunResponse {
1841            ok: false,
1842            message: "empty session id".into(),
1843        });
1844    }
1845    // Reuse the same command channel sentinel mechanism that powers
1846    // __reset_conversation__ — main.rs interprets __load_session__:<id> and
1847    // swaps the live `conv_history` with the loaded session's messages.
1848    let sentinel = format!("__load_session__:{}", id);
1849    match &state.command_tx {
1850        Some(tx) if tx.send(sentinel).is_ok() => axum::extract::Json(RunResponse {
1851            ok: true,
1852            message: "session load requested".into(),
1853        }),
1854        _ => axum::extract::Json(RunResponse {
1855            ok: false,
1856            message: "console command channel unavailable".into(),
1857        }),
1858    }
1859}
1860
1861async fn list_sessions() -> axum::extract::Json<serde_json::Value> {
1862    // Resolve the same DB path the CLI uses. Failures degrade to an empty list
1863    // rather than 500'ing the WebView panel.
1864    let db_path = session_db_path();
1865    let store = match crate::runtime::session::SessionStore::open(&db_path) {
1866        Ok(s) => s,
1867        Err(e) => {
1868            return axum::extract::Json(serde_json::json!({
1869                "ok": false,
1870                "message": format!("could not open session db: {}", e),
1871                "db_path": db_path.to_string_lossy(),
1872                "sessions": [],
1873            }));
1874        }
1875    };
1876    let sessions = store.list();
1877    axum::extract::Json(serde_json::json!({
1878        "ok": true,
1879        "db_path": db_path.to_string_lossy(),
1880        "sessions": sessions,
1881    }))
1882}
1883
1884async fn get_history(
1885    axum::extract::Query(query): axum::extract::Query<HistoryQuery>,
1886) -> axum::extract::Json<HistoryResponse> {
1887    let db_path = session_db_path();
1888    let store = match crate::runtime::session::SessionStore::open(&db_path) {
1889        Ok(s) => s,
1890        Err(e) => {
1891            return axum::extract::Json(HistoryResponse {
1892                ok: false,
1893                message: format!("could not open session db: {}", e),
1894                inputs: Vec::new(),
1895            });
1896        }
1897    };
1898    axum::extract::Json(HistoryResponse {
1899        ok: true,
1900        message: "loaded".into(),
1901        inputs: store.recent_inputs(query.limit.unwrap_or(50)),
1902    })
1903}
1904
1905/// Common local dev-server ports the Preview panel probes: Node/CRA/Express
1906/// (3000/3001), Angular (4200), Astro (4321), Vite (5173/5174), Flask/uvicorn
1907/// (5000/8000), php/http.server (8080/8081/8888), Hugo (1313), Streamlit (8501).
1908const PREVIEW_SCAN_PORTS: &[u16] = &[
1909    3000, 3001, 4200, 4321, 5000, 5173, 5174, 8000, 8080, 8081, 8501, 8888, 1313,
1910];
1911
1912/// `GET /preview/scan` — probe the loopback interface for live HTTP dev
1913/// servers so the Preview panel shows what is actually running (no guessing,
1914/// no fakes). The console's own port is excluded via the request Host header.
1915async fn scan_preview_servers(
1916    headers: axum::http::HeaderMap,
1917) -> axum::extract::Json<serde_json::Value> {
1918    let self_port: Option<u16> = headers
1919        .get(axum::http::header::HOST)
1920        .and_then(|h| h.to_str().ok())
1921        .and_then(|h| h.rsplit(':').next())
1922        .and_then(|p| p.parse().ok());
1923    let client = match reqwest::Client::builder()
1924        .timeout(Duration::from_millis(600))
1925        .redirect(reqwest::redirect::Policy::none())
1926        .build()
1927    {
1928        Ok(c) => c,
1929        Err(_) => {
1930            return axum::extract::Json(serde_json::json!({ "ok": false, "servers": [] }));
1931        }
1932    };
1933    let probes = PREVIEW_SCAN_PORTS
1934        .iter()
1935        .copied()
1936        .filter(|p| Some(*p) != self_port)
1937        .map(|port| {
1938            let client = client.clone();
1939            async move {
1940                let url = format!("http://127.0.0.1:{port}/");
1941                let resp = client.get(&url).send().await.ok()?;
1942                let status = resp.status().as_u16();
1943                // Any HTTP answer (including 404) means a live server.
1944                Some(serde_json::json!({ "url": url, "port": port, "status": status }))
1945            }
1946        });
1947    let servers: Vec<serde_json::Value> = futures::future::join_all(probes)
1948        .await
1949        .into_iter()
1950        .flatten()
1951        .collect();
1952    axum::extract::Json(serde_json::json!({ "ok": true, "servers": servers }))
1953}
1954
1955/// `GET /todos` — read-only view over the `todo` tool's SQLite table so the
1956/// WebView Plan panel can render the agent's real task list (objective steps,
1957/// status). Reads the same DB the tool writes (`state_dir/sparrow/sparrow.db`);
1958/// a missing DB or table degrades to an empty list instead of a 500.
1959async fn list_todos() -> axum::extract::Json<serde_json::Value> {
1960    let db_path = dirs::state_dir()
1961        .or_else(dirs::data_local_dir)
1962        .or_else(dirs::data_dir)
1963        .unwrap_or_else(|| std::path::PathBuf::from("."))
1964        .join("sparrow")
1965        .join("sparrow.db");
1966    let todos = tokio::task::spawn_blocking(move || -> Vec<serde_json::Value> {
1967        let Ok(conn) = rusqlite::Connection::open_with_flags(
1968            &db_path,
1969            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
1970        ) else {
1971            return Vec::new();
1972        };
1973        let Ok(mut stmt) = conn.prepare(
1974            "SELECT id, content, status, updated_at FROM todos ORDER BY created_at LIMIT 100",
1975        ) else {
1976            return Vec::new();
1977        };
1978        stmt.query_map([], |row| {
1979            Ok(serde_json::json!({
1980                "id": row.get::<_, String>(0)?,
1981                "content": row.get::<_, String>(1)?,
1982                "status": row.get::<_, String>(2)?,
1983                "updated_at": row.get::<_, i64>(3)?,
1984            }))
1985        })
1986        .map(|rows| rows.filter_map(|r| r.ok()).collect())
1987        .unwrap_or_default()
1988    })
1989    .await
1990    .unwrap_or_default();
1991    axum::extract::Json(serde_json::json!({ "ok": true, "todos": todos }))
1992}
1993
1994async fn intel_cache_from_state(
1995    state: &AppState,
1996) -> anyhow::Result<(sparrow_intel::IntelCache, bool, usize)> {
1997    let (state_dir, enabled, source_count) = state
1998        .config
1999        .as_ref()
2000        .and_then(|cfg| {
2001            cfg.read()
2002                .ok()
2003                .map(|c| (c.state_dir.clone(), c.intel.enabled, c.intel.sources.len()))
2004        })
2005        .unwrap_or_else(|| {
2006            (
2007                dirs::state_dir()
2008                    .or_else(dirs::data_local_dir)
2009                    .or_else(dirs::data_dir)
2010                    .unwrap_or_else(|| std::path::PathBuf::from("."))
2011                    .join("sparrow"),
2012                false,
2013                0,
2014            )
2015        });
2016    let cache = sparrow_intel::IntelCache::open(sparrow_intel::default_cache_path(&state_dir))?;
2017    Ok((cache, enabled, source_count))
2018}
2019
2020/// `GET /intel/digests` — read cached public release digests. This endpoint is
2021/// deliberately network-free; `sparrow intel scan` is the opt-in fetch step.
2022async fn get_intel_digests(
2023    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2024) -> axum::extract::Json<serde_json::Value> {
2025    match intel_cache_from_state(&state).await {
2026        Ok((cache, enabled, source_count)) => {
2027            let digests = cache.digests(30).unwrap_or_default();
2028            axum::extract::Json(serde_json::json!({
2029                "ok": true,
2030                "enabled": enabled,
2031                "sources": source_count,
2032                "digests": digests,
2033            }))
2034        }
2035        Err(e) => axum::extract::Json(serde_json::json!({
2036            "ok": false,
2037            "message": e.to_string(),
2038            "digests": [],
2039        })),
2040    }
2041}
2042
2043/// `GET /intel/backlog` — scored tickets derived from cached digests. No
2044/// network access; empty cache means honest empty state.
2045async fn get_intel_backlog(
2046    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2047) -> axum::extract::Json<serde_json::Value> {
2048    match intel_cache_from_state(&state).await {
2049        Ok((cache, enabled, source_count)) => {
2050            let tickets = cache.backlog(30).unwrap_or_default();
2051            axum::extract::Json(serde_json::json!({
2052                "ok": true,
2053                "enabled": enabled,
2054                "sources": source_count,
2055                "tickets": tickets,
2056            }))
2057        }
2058        Err(e) => axum::extract::Json(serde_json::json!({
2059            "ok": false,
2060            "message": e.to_string(),
2061            "tickets": [],
2062        })),
2063    }
2064}
2065
2066fn session_db_path() -> std::path::PathBuf {
2067    dirs::state_dir()
2068        .or_else(dirs::data_local_dir)
2069        .or_else(dirs::data_dir)
2070        .unwrap_or_else(|| std::path::PathBuf::from("."))
2071        .join("sparrow")
2072        .join("sessions.db")
2073}
2074
2075/// Default-profile transcripts dir — mirrors the resolution in main.rs.
2076/// (Profile-scoped consoles record under profiles/<name>/transcripts; the
2077/// replay endpoints serve the default tree, which is what `sparrow console`
2078/// without --profile uses.)
2079fn transcripts_dir() -> std::path::PathBuf {
2080    dirs::state_dir()
2081        .or_else(dirs::data_local_dir)
2082        .or_else(dirs::data_dir)
2083        .unwrap_or_else(|| std::path::PathBuf::from("."))
2084        .join("sparrow")
2085        .join("transcripts")
2086}
2087
2088#[derive(serde::Deserialize)]
2089struct ReplayQuery {
2090    #[serde(default)]
2091    run_id: Option<String>,
2092}
2093
2094/// `GET /replays` — list recorded run transcripts (most recent first) so the
2095/// WebView replay button can offer real runs instead of a blind prompt.
2096async fn list_replays() -> axum::extract::Json<serde_json::Value> {
2097    use crate::runtime::recorder::{FsRecorder, Replayer};
2098    let rec = FsRecorder::new(transcripts_dir());
2099    let mut items: Vec<serde_json::Value> = rec
2100        .list_transcripts()
2101        .into_iter()
2102        .map(|id| {
2103            let meta_path = transcripts_dir().join(&id).join("meta.json");
2104            let inputs_path = transcripts_dir().join(&id).join("inputs.json");
2105            let meta: serde_json::Value = std::fs::read_to_string(&meta_path)
2106                .ok()
2107                .and_then(|s| serde_json::from_str(&s).ok())
2108                .unwrap_or(serde_json::Value::Null);
2109            let task = std::fs::read_to_string(&inputs_path)
2110                .ok()
2111                .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
2112                .and_then(|v| v.get("task").and_then(|t| t.as_str()).map(String::from))
2113                .unwrap_or_default();
2114            serde_json::json!({
2115                "run_id": id,
2116                "task": task,
2117                "event_count": meta.get("event_count").cloned().unwrap_or(0.into()),
2118                "created_at": meta.get("created_at").cloned().unwrap_or("".into()),
2119            })
2120        })
2121        .collect();
2122    // meta.created_at is "YYYY-MM-DD HH:MM:SS" — lexicographic sort works.
2123    items.sort_by(|a, b| {
2124        b["created_at"]
2125            .as_str()
2126            .unwrap_or("")
2127            .cmp(a["created_at"].as_str().unwrap_or(""))
2128    });
2129    axum::extract::Json(serde_json::json!({ "ok": true, "replays": items }))
2130}
2131
2132/// `GET /runs` — WebView view of known autonomous/long runs. Active local
2133/// sidebar tasks are still sourced from WS events; persisted runs come from
2134/// transcripts so the panel remains useful across refreshes.
2135async fn list_runs() -> axum::extract::Json<serde_json::Value> {
2136    use crate::runtime::recorder::{FsRecorder, Replayer};
2137    let rec = FsRecorder::new(transcripts_dir());
2138    let mut runs: Vec<serde_json::Value> = rec
2139        .list_transcripts()
2140        .into_iter()
2141        .map(|id| {
2142            let meta_path = transcripts_dir().join(&id).join("meta.json");
2143            let inputs_path = transcripts_dir().join(&id).join("inputs.json");
2144            let meta: serde_json::Value = std::fs::read_to_string(&meta_path)
2145                .ok()
2146                .and_then(|s| serde_json::from_str(&s).ok())
2147                .unwrap_or(serde_json::Value::Null);
2148            let task = std::fs::read_to_string(&inputs_path)
2149                .ok()
2150                .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
2151                .and_then(|v| v.get("task").and_then(|t| t.as_str()).map(String::from))
2152                .unwrap_or_default();
2153            serde_json::json!({
2154                "run_id": id,
2155                "task": task,
2156                "status": "recorded",
2157                "active": false,
2158                "event_count": meta.get("event_count").cloned().unwrap_or(0.into()),
2159                "created_at": meta.get("created_at").cloned().unwrap_or("".into()),
2160            })
2161        })
2162        .collect();
2163    runs.sort_by(|a, b| {
2164        b["created_at"]
2165            .as_str()
2166            .unwrap_or("")
2167            .cmp(a["created_at"].as_str().unwrap_or(""))
2168    });
2169    axum::extract::Json(serde_json::json!({
2170        "ok": true,
2171        "active": [],
2172        "runs": runs,
2173    }))
2174}
2175
2176/// `GET /replay?run_id=<id>` — return a recorded transcript's public events so
2177/// the WebView can re-render the run. Without `run_id`, replays the most
2178/// recent transcript. The chrome ▸ replay button has pointed here since v0.3;
2179/// the endpoint finally exists.
2180async fn replay_run(
2181    axum::extract::Query(q): axum::extract::Query<ReplayQuery>,
2182) -> axum::extract::Json<serde_json::Value> {
2183    use crate::runtime::recorder::{FsRecorder, Replayer};
2184    let rec = FsRecorder::new(transcripts_dir());
2185    let run_id = match q.run_id.filter(|s| !s.trim().is_empty()) {
2186        Some(id) => id.trim().to_string(),
2187        None => {
2188            // Most recent transcript by meta.created_at.
2189            let mut best: Option<(String, String)> = None;
2190            for id in rec.list_transcripts() {
2191                let created =
2192                    std::fs::read_to_string(transcripts_dir().join(&id).join("meta.json"))
2193                        .ok()
2194                        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
2195                        .and_then(|v| {
2196                            v.get("created_at")
2197                                .and_then(|c| c.as_str())
2198                                .map(String::from)
2199                        })
2200                        .unwrap_or_default();
2201                if best.as_ref().map(|(_, c)| created > *c).unwrap_or(true) {
2202                    best = Some((id, created));
2203                }
2204            }
2205            match best {
2206                Some((id, _)) => id,
2207                None => {
2208                    return axum::extract::Json(serde_json::json!({
2209                        "ok": false,
2210                        "message": "no recorded runs yet — run a task first",
2211                        "events": [],
2212                    }));
2213                }
2214            }
2215        }
2216    };
2217    // Path-traversal guard: transcript ids are directory names, never paths.
2218    if run_id.contains(['/', '\\', '.']) {
2219        return axum::extract::Json(serde_json::json!({
2220            "ok": false, "message": "invalid run id", "events": [],
2221        }));
2222    }
2223    match rec.load(&run_id) {
2224        Some(t) => {
2225            let events: Vec<&Event> = t.events.iter().filter(|e| e.is_public()).collect();
2226            axum::extract::Json(serde_json::json!({
2227                "ok": true,
2228                "run_id": t.run_id,
2229                "task": t.inputs.task,
2230                "events": events,
2231            }))
2232        }
2233        None => axum::extract::Json(serde_json::json!({
2234            "ok": false,
2235            "message": format!("transcript '{}' not found", run_id),
2236            "events": [],
2237        })),
2238    }
2239}
2240
2241/// `GET /mcp/list` — list configured MCP connectors (from the same
2242/// `mcp_servers.json` the runtime loads) so the config panel's "MCP" tab shows
2243/// real state instead of "not exposed yet".
2244async fn list_mcp_servers(
2245    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2246) -> axum::extract::Json<serde_json::Value> {
2247    use crate::capabilities::mcp::{BasicMcpClient, McpClient};
2248    let config_dir = state
2249        .config
2250        .as_ref()
2251        .and_then(|cfg| cfg.read().ok().map(|c| c.config_dir.clone()))
2252        .unwrap_or_else(|| {
2253            dirs::config_dir()
2254                .unwrap_or_else(|| std::path::PathBuf::from("."))
2255                .join("sparrow")
2256        });
2257    let client = BasicMcpClient::new(config_dir.clone());
2258    let servers: Vec<serde_json::Value> = client
2259        .list_servers()
2260        .await
2261        .into_iter()
2262        .map(|s| {
2263            serde_json::json!({
2264                "name": s.name,
2265                "transport": format!("{:?}", s.transport).to_lowercase(),
2266                "command": s.command,
2267                "args": s.args,
2268                "url": s.url,
2269                "allow_tools": s.allow_tools,
2270            })
2271        })
2272        .collect();
2273    axum::extract::Json(serde_json::json!({
2274        "ok": true,
2275        "servers": servers,
2276        "config_path": config_dir.join("mcp_servers.json").to_string_lossy(),
2277    }))
2278}
2279
2280/// `GET /hooks` — list configured lifecycle hooks from the live config so the
2281/// config panel's "hooks" tab shows real state.
2282async fn list_hooks(
2283    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2284) -> axum::extract::Json<serde_json::Value> {
2285    let hooks = state
2286        .config
2287        .as_ref()
2288        .and_then(|cfg| cfg.read().ok().map(|c| c.hooks.clone()))
2289        .unwrap_or_default();
2290    axum::extract::Json(serde_json::json!({ "ok": true, "hooks": hooks }))
2291}
2292
2293async fn get_security(
2294    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2295) -> axum::extract::Json<serde_json::Value> {
2296    let Some(shared) = &state.config else {
2297        return axum::extract::Json(serde_json::json!({
2298            "ok": false,
2299            "message": "config unavailable",
2300        }));
2301    };
2302    let cfg = shared.read().expect("config lock poisoned").clone();
2303    let audit = crate::security::SecurityAudit::run(&cfg, &cfg.hooks);
2304    axum::extract::Json(serde_json::json!({
2305        "ok": true,
2306        "audit": audit,
2307    }))
2308}
2309
2310async fn get_permissions(
2311    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2312) -> axum::extract::Json<PermissionsResponse> {
2313    let Some(shared) = &state.config else {
2314        return axum::extract::Json(PermissionsResponse {
2315            ok: false,
2316            message: "config unavailable".into(),
2317            permissions: None,
2318            persisted_tools: std::collections::HashMap::new(),
2319        });
2320    };
2321    let cfg = shared.read().expect("config lock poisoned").clone();
2322    let persisted_tools = cfg.permissions.store.to_api_map();
2323    axum::extract::Json(PermissionsResponse {
2324        ok: true,
2325        message: "loaded".into(),
2326        permissions: Some(cfg.permissions),
2327        persisted_tools,
2328    })
2329}
2330
2331async fn save_permissions(
2332    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2333    axum::extract::Json(req): axum::extract::Json<PermissionsRequest>,
2334) -> axum::extract::Json<RunResponse> {
2335    let Some(shared) = &state.config else {
2336        return axum::extract::Json(RunResponse {
2337            ok: false,
2338            message: "config unavailable".into(),
2339        });
2340    };
2341    let mut cfg = shared.write().expect("config lock poisoned");
2342
2343    // ── Global permission mode ───────────────────────────────────────────
2344    if let Some(mode) = req.mode.as_deref() {
2345        let Some(mode) = crate::permissions::PermissionMode::parse(mode) else {
2346            return axum::extract::Json(RunResponse {
2347                ok: false,
2348                message: "unknown permission mode".into(),
2349            });
2350        };
2351        cfg.defaults.autonomy = mode.autonomy_level();
2352        cfg.permissions.mode = mode;
2353    }
2354
2355    // ── Per-tool persisted decisions ──────────────────────────────────────
2356    if let Some(tools) = &req.tools {
2357        for (tool_name, decision_str) in tools {
2358            let decision = match decision_str.as_str() {
2359                "allow_always" => crate::event::Decision::AllowAlways,
2360                "allow_session" => crate::event::Decision::AllowSession,
2361                "deny" => crate::event::Decision::Deny,
2362                "ask_user" => crate::event::Decision::AskUser,
2363                _ => continue,
2364            };
2365            let config_dir = cfg.config_dir.clone();
2366            let _ = cfg
2367                .permissions
2368                .store
2369                .set_and_save(tool_name, &decision, &config_dir);
2370        }
2371    }
2372
2373    let saved = cfg.clone();
2374    let store = FsConfigStore::new(saved.config_dir.clone());
2375    if let Err(err) = store.save(&saved) {
2376        return axum::extract::Json(RunResponse {
2377            ok: false,
2378            message: format!("permissions save failed: {}", err),
2379        });
2380    }
2381    axum::extract::Json(RunResponse {
2382        ok: true,
2383        message: "permissions saved".into(),
2384    })
2385}
2386
2387fn parse_autonomy(value: Option<&str>) -> Option<crate::event::AutonomyLevel> {
2388    match value.map(|s| s.trim().to_lowercase()).as_deref() {
2389        Some("supervised") => Some(crate::event::AutonomyLevel::Supervised),
2390        Some("trusted") => Some(crate::event::AutonomyLevel::Trusted),
2391        Some("autonomous") => Some(crate::event::AutonomyLevel::Autonomous),
2392        _ => None,
2393    }
2394}
2395
2396// ─── Provider model scan ──────────────────────────────────────────────────────
2397
2398#[derive(serde::Deserialize)]
2399struct ScanRequest {
2400    provider: String,
2401}
2402
2403#[derive(serde::Serialize)]
2404struct ScanResponse {
2405    ok: bool,
2406    message: String,
2407    models: Vec<String>,
2408}
2409
2410async fn scan_provider_models(
2411    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2412    axum::extract::Json(req): axum::extract::Json<ScanRequest>,
2413) -> axum::extract::Json<ScanResponse> {
2414    use crate::config::providers::find_provider;
2415
2416    let provider_id = req.provider.trim().to_string();
2417
2418    let Some(def) = find_provider(&provider_id) else {
2419        return axum::extract::Json(ScanResponse {
2420            ok: false,
2421            message: format!("Unknown provider: {}", provider_id),
2422            models: vec![],
2423        });
2424    };
2425
2426    // Resolve API key: auth store -> env var
2427    let api_key = {
2428        let key_from_store = state.config.as_ref().and_then(|cfg| {
2429            let c = cfg.read().ok()?;
2430            let auth = crate::auth::store::ChainedAuthStore::new(c.config_dir.clone());
2431            match auth.get(&provider_id) {
2432                Some(crate::auth::Credential::ApiKey(k)) => Some(k.expose_secret().to_string()),
2433                _ => None,
2434            }
2435        });
2436        let key_from_env = def
2437            .api_key_env
2438            .as_deref()
2439            .and_then(|env| std::env::var(env).ok());
2440        key_from_store.or(key_from_env).unwrap_or_default()
2441    };
2442
2443    match crate::provider::discovery::discover_models(&def.adapter, &def.base_url, &api_key).await {
2444        Ok(models) => {
2445            let count = models.len();
2446            axum::extract::Json(ScanResponse {
2447                ok: true,
2448                message: format!("Found {} model(s) for {}", count, def.label),
2449                models,
2450            })
2451        }
2452        Err(err) => axum::extract::Json(ScanResponse {
2453            ok: false,
2454            message: format!("Scan failed: {}", err),
2455            models: vec![],
2456        }),
2457    }
2458}
2459
2460// ─── Routing config get/set ───────────────────────────────────────────────────
2461
2462#[derive(serde::Serialize)]
2463struct RoutingResponse {
2464    ok: bool,
2465    preferred_provider: Option<String>,
2466    preferred_model: Option<String>,
2467    routing_mode: String,
2468    auto_discover: bool,
2469    all_providers: Vec<String>,
2470}
2471
2472#[derive(serde::Deserialize)]
2473struct RoutingRequest {
2474    preferred_provider: Option<String>,
2475    preferred_model: Option<String>,
2476    routing_mode: Option<String>,
2477    auto_discover: Option<bool>,
2478}
2479
2480async fn get_routing(
2481    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2482) -> axum::extract::Json<RoutingResponse> {
2483    use crate::config::providers::provider_registry;
2484
2485    let all_providers: Vec<String> = provider_registry().iter().map(|p| p.id.clone()).collect();
2486
2487    let Some(shared) = &state.config else {
2488        return axum::extract::Json(RoutingResponse {
2489            ok: false,
2490            preferred_provider: None,
2491            preferred_model: None,
2492            routing_mode: "auto".into(),
2493            auto_discover: true,
2494            all_providers,
2495        });
2496    };
2497
2498    let cfg = shared.read().expect("config lock poisoned");
2499    axum::extract::Json(RoutingResponse {
2500        ok: true,
2501        preferred_provider: cfg.routing.preferred_provider.clone(),
2502        preferred_model: cfg.routing.preferred_model.clone(),
2503        routing_mode: cfg.routing.routing_mode.clone(),
2504        auto_discover: cfg.routing.auto_discover,
2505        all_providers,
2506    })
2507}
2508
2509async fn save_routing(
2510    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2511    axum::extract::Json(req): axum::extract::Json<RoutingRequest>,
2512) -> axum::extract::Json<RunResponse> {
2513    let Some(shared) = &state.config else {
2514        return axum::extract::Json(RunResponse {
2515            ok: false,
2516            message: "config unavailable".into(),
2517        });
2518    };
2519
2520    {
2521        let mut cfg = shared.write().expect("config lock poisoned");
2522
2523        // preferred_provider: empty string or missing = clear
2524        cfg.routing.preferred_provider = req
2525            .preferred_provider
2526            .map(|s| s.trim().to_string())
2527            .filter(|s| !s.is_empty());
2528
2529        cfg.routing.preferred_model = req
2530            .preferred_model
2531            .map(|s| s.trim().to_string())
2532            .filter(|s| !s.is_empty());
2533
2534        if let Some(ref mode) = req.routing_mode {
2535            cfg.routing.routing_mode = mode.trim().to_string();
2536        }
2537
2538        if let Some(ad) = req.auto_discover {
2539            cfg.routing.auto_discover = ad;
2540        }
2541
2542        let saved = cfg.clone();
2543        let store = FsConfigStore::new(saved.config_dir.clone());
2544        if let Err(err) = store.save(&saved) {
2545            return axum::extract::Json(RunResponse {
2546                ok: false,
2547                message: format!("save failed: {}", err),
2548            });
2549        }
2550    }
2551
2552    axum::extract::Json(RunResponse {
2553        ok: true,
2554        message: "Routing preferences saved.".into(),
2555    })
2556}
2557
2558/// Serialize an event to JSON for the WS feed. In simple mode, attach a
2559/// plain-language `human` line (from the one humanize table) and a `simple`
2560/// flag so the frontend can render the human layer without duplicating the
2561/// table. Returns `None` only if serialization fails.
2562fn encode_event(event: &Event, simple: bool, lang: crate::humanize::Lang) -> Option<String> {
2563    if !simple {
2564        return serde_json::to_string(event).ok();
2565    }
2566    let mut value = serde_json::to_value(event).ok()?;
2567    if let Some(obj) = value.as_object_mut() {
2568        obj.insert("simple".into(), serde_json::Value::Bool(true));
2569        if let Some(human) = crate::humanize::humanize(event, lang) {
2570            obj.insert("human".into(), serde_json::Value::String(human));
2571        }
2572    }
2573    serde_json::to_string(&value).ok()
2574}
2575
2576async fn handle_ws(
2577    mut socket: axum::extract::ws::WebSocket,
2578    mut event_rx: tokio::sync::broadcast::Receiver<Event>,
2579    snapshot: Vec<Event>,
2580    simple: bool,
2581    lang: crate::humanize::Lang,
2582) {
2583    tracing::info!("WebSocket connected, replaying {} events", snapshot.len());
2584    // Replay the current run so far, so a refresh mid-run never shows a
2585    // blank feed. The snapshot only holds public events.
2586    for event in &snapshot {
2587        if let Some(json) = encode_event(event, simple, lang) {
2588            use axum::extract::ws::Message;
2589            if socket.send(Message::Text(json.into())).await.is_err() {
2590                return;
2591            }
2592        }
2593    }
2594    loop {
2595        tokio::select! {
2596            result = event_rx.recv() => {
2597                match result {
2598                    Ok(event) => {
2599                        if !event.is_public() {
2600                            continue;
2601                        }
2602                        if let Some(json) = encode_event(&event, simple, lang) {
2603                            use axum::extract::ws::Message;
2604                            if socket.send(Message::Text(json.into())).await.is_err() {
2605                                break;
2606                            }
2607                        }
2608                    }
2609                    Err(_) => break,
2610                }
2611            }
2612            _ = tokio::time::sleep(tokio::time::Duration::from_secs(30)) => {
2613                // Ping keep-alive
2614                use axum::extract::ws::Message;
2615                if socket.send(Message::Ping(vec![])).await.is_err() {
2616                    break;
2617                }
2618            }
2619        }
2620    }
2621}
2622
2623#[cfg(test)]
2624mod tests {
2625    use super::*;
2626
2627    #[test]
2628    fn webview_cli_args_maps_model_alias() {
2629        assert_eq!(
2630            webview_cli_args("/models").unwrap(),
2631            vec!["model".to_string(), "--list".to_string()]
2632        );
2633    }
2634
2635    #[test]
2636    fn webview_cli_args_keeps_quoted_arguments() {
2637        assert_eq!(
2638            webview_cli_args("/auth add \"open router\"").unwrap(),
2639            vec![
2640                "auth".to_string(),
2641                "add".to_string(),
2642                "open router".to_string()
2643            ]
2644        );
2645    }
2646
2647    #[test]
2648    fn webview_cli_args_joins_run_task() {
2649        assert_eq!(
2650            webview_cli_args("/run analyse le repo github").unwrap(),
2651            vec!["run".to_string(), "analyse le repo github".to_string()]
2652        );
2653    }
2654
2655    #[test]
2656    fn webview_cli_blocks_interactive_commands() {
2657        let args = webview_cli_args("/console --port 9339").unwrap();
2658        assert!(blocked_webview_cli_command(&args).is_some());
2659        let args = webview_cli_args("/gateway start").unwrap();
2660        assert!(blocked_webview_cli_command(&args).is_some());
2661    }
2662
2663    #[test]
2664    fn resolve_bind_defaults_to_loopback() {
2665        let t = resolve_bind_addr(None, 9339).unwrap();
2666        assert_eq!(t.addr.ip().to_string(), "127.0.0.1");
2667        assert_eq!(t.addr.port(), 9339);
2668        assert!(
2669            !t.is_public,
2670            "default bind must not be reachable from the LAN"
2671        );
2672    }
2673
2674    #[test]
2675    fn resolve_bind_honours_explicit_public_ip_and_flags_it() {
2676        let t = resolve_bind_addr(Some("0.0.0.0"), 8080).unwrap();
2677        assert_eq!(t.addr.ip().to_string(), "0.0.0.0");
2678        assert!(
2679            t.is_public,
2680            "0.0.0.0 must be flagged as public for the warning"
2681        );
2682    }
2683
2684    #[test]
2685    fn resolve_bind_rejects_value_with_port() {
2686        // D3: a port inside --bind used to be silently ignored.
2687        let err = resolve_bind_addr(Some("127.0.0.1:9876"), 9339).unwrap_err();
2688        assert!(err.to_string().contains("--bind"));
2689    }
2690
2691    #[test]
2692    fn resolve_bind_rejects_garbage() {
2693        assert!(resolve_bind_addr(Some("not-an-ip"), 9339).is_err());
2694    }
2695
2696    #[test]
2697    fn resolve_bind_blank_is_loopback() {
2698        let t = resolve_bind_addr(Some("   "), 9339).unwrap();
2699        assert_eq!(t.addr.ip().to_string(), "127.0.0.1");
2700    }
2701}