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