Skip to main content

superfluid_daemon/
launch.rs

1//! `superfluid launch <agent>`: run a coding agent pointed at a local server, starting one when
2//! none answers. Each agent is a row of [`AGENTS`] whose [`Plan`] is what would run.
3
4use std::path::{Path, PathBuf};
5use std::time::{Duration, Instant};
6
7use crate::cli::DEFAULT_HTTP;
8
9/// The server an agent is pointed at.
10#[derive(Debug, Clone)]
11pub struct Target {
12    /// `http://host:port`, no trailing slash.
13    pub base: String,
14    /// The served model id the agent asks for.
15    pub model: String,
16    /// The server's context window per conversation, when it says.
17    pub n_ctx: Option<u64>,
18    /// The key the server wants, when it runs behind `--api-key`.
19    pub api_key: Option<String>,
20}
21
22impl Target {
23    /// What the agent sends as its API key: the server's, or a placeholder for a keyless server
24    /// (agents refuse to start without one).
25    fn key(&self) -> &str {
26        self.api_key.as_deref().unwrap_or("superfluid")
27    }
28}
29
30/// What `launch` runs.
31#[derive(Debug, Clone, Default, PartialEq)]
32pub struct Plan {
33    pub program: String,
34    pub args: Vec<String>,
35    pub env: Vec<(String, String)>,
36    /// Variables set only where the user's environment does not already set them.
37    pub env_default: Vec<(String, String)>,
38    /// Variables removed from the agent's environment.
39    pub env_remove: Vec<String>,
40    /// Commands of the agent's own run before it starts, each to success (setup it does itself).
41    pub before: Vec<Vec<String>>,
42    /// Files written before the agent starts (config the agent reads by path), in launch's own
43    /// directory: launch never edits an agent's own config.
44    pub files: Vec<(PathBuf, String)>,
45    /// Lines printed before the agent starts.
46    pub notes: Vec<String>,
47}
48
49pub struct Agent {
50    pub name: &'static str,
51    pub aliases: &'static [&'static str],
52    pub title: &'static str,
53    pub binary: &'static str,
54    pub install: &'static str,
55    plan: fn(&Target, &[String]) -> Plan,
56}
57
58pub const AGENTS: &[Agent] = &[
59    Agent {
60        name: "claude",
61        aliases: &["claude-code"],
62        title: "Claude Code",
63        binary: "claude",
64        install: "curl -fsSL https://claude.ai/install.sh | bash",
65        plan: claude,
66    },
67    Agent {
68        name: "codex",
69        aliases: &["codex-cli"],
70        title: "Codex",
71        binary: "codex",
72        install: "npm install -g @openai/codex",
73        plan: codex,
74    },
75    Agent {
76        name: "pi",
77        aliases: &["pi-coding-agent"],
78        title: "pi",
79        binary: "pi",
80        install: "npm install -g @earendil-works/pi-coding-agent",
81        plan: pi,
82    },
83    Agent {
84        name: "opencode",
85        aliases: &[],
86        title: "OpenCode",
87        binary: "opencode",
88        install: "curl -fsSL https://opencode.ai/install | bash",
89        plan: opencode,
90    },
91    Agent {
92        name: "hermes",
93        aliases: &["hermes-agent"],
94        title: "Hermes Agent",
95        binary: "hermes",
96        install: "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash",
97        plan: hermes,
98    },
99    Agent {
100        name: "cline",
101        aliases: &["cline-cli"],
102        title: "Cline",
103        binary: "cline",
104        install: "npm install -g cline",
105        plan: cline,
106    },
107    Agent {
108        name: "openclaw",
109        aliases: &["clawdbot", "moltbot"],
110        title: "OpenClaw",
111        binary: "openclaw",
112        install: "npm install -g openclaw@latest",
113        plan: openclaw,
114    },
115    Agent {
116        name: "ollama",
117        aliases: &[],
118        title: "Ollama CLI",
119        binary: "ollama",
120        install: "https://ollama.com/download",
121        plan: ollama,
122    },
123];
124
125/// Claude Code over the Anthropic Messages API; every model tier is the served model.
126fn claude(t: &Target, extra: &[String]) -> Plan {
127    let mut env = vec![
128        ("ANTHROPIC_BASE_URL".to_string(), t.base.clone()),
129        ("ANTHROPIC_AUTH_TOKEN".to_string(), t.key().to_string()),
130    ];
131    for var in [
132        "ANTHROPIC_MODEL",
133        "ANTHROPIC_DEFAULT_OPUS_MODEL",
134        "ANTHROPIC_DEFAULT_SONNET_MODEL",
135        "ANTHROPIC_DEFAULT_HAIKU_MODEL",
136        "ANTHROPIC_SMALL_FAST_MODEL",
137        "CLAUDE_CODE_SUBAGENT_MODEL",
138    ] {
139        env.push((var.to_string(), t.model.clone()));
140    }
141    // Without this a per-session line at the top of the system prompt defeats prefix reuse.
142    env.push(("CLAUDE_CODE_ATTRIBUTION_HEADER".to_string(), "0".to_string()));
143    let mut notes = Vec::new();
144    // Told the server's window, so a smaller one compacts in time.
145    if let Some(n) = t.n_ctx {
146        env.push(("CLAUDE_CODE_MAX_CONTEXT_TOKENS".to_string(), n.to_string()));
147    }
148    if let Some(n) = t.n_ctx.filter(|&n| n < 200_000) {
149        env.push(("CLAUDE_CODE_AUTO_COMPACT_WINDOW".to_string(), n.to_string()));
150        if n < 32_768 {
151            notes.push(format!(
152                "the server's context window is {n} tokens; Claude Code's first request alone is about 17k. \
153                 Restart the server with --max-context 65536 or more."
154            ));
155        }
156    }
157    Plan {
158        program: "claude".into(),
159        args: extra.to_vec(),
160        env,
161        // Auto mode's permission classifier runs on Anthropic's servers unless told otherwise.
162        env_default: vec![("CLAUDE_CODE_AUTO_MODE_SERVER".into(), "0".into())],
163        // An API key in the environment would be sent beside the token above.
164        env_remove: vec!["ANTHROPIC_API_KEY".into()],
165        notes,
166        ..Plan::default()
167    }
168}
169
170/// The window an agent is told when the server does not say.
171const FALLBACK_WINDOW: u64 = 128_000;
172
173/// A TOML string (`-c key=value` values are TOML).
174fn toml_str(s: &str) -> String {
175    serde_json::to_string(s).expect("a string serializes")
176}
177
178/// Codex over the Responses API: a provider declared with `-c` overrides and a one-model catalog
179/// carrying the window. Hosted web search is off; `apply_patch` is sent as a function.
180fn codex(t: &Target, extra: &[String]) -> Plan {
181    let catalog = state_dir().join("codex-models.json");
182    let entry = serde_json::json!({
183        "slug": t.model,
184        "display_name": t.model,
185        "context_window": t.n_ctx.unwrap_or(FALLBACK_WINDOW),
186        "shell_type": "default",
187        "visibility": "list",
188        "supported_in_api": true,
189        "priority": 0,
190        "truncation_policy": {"mode": "bytes", "limit": 10000},
191        "input_modalities": ["text"],
192        "base_instructions": "",
193        "support_verbosity": false,
194        "supports_parallel_tool_calls": false,
195        "supports_reasoning_summaries": false,
196        "supported_reasoning_levels": [],
197        "experimental_supported_tools": [],
198    });
199    let text = serde_json::to_string_pretty(&serde_json::json!({"models": [entry]})).expect("json") + "\n";
200    let mut args = Vec::new();
201    for (k, v) in [
202        ("model_provider", toml_str("superfluid")),
203        ("model_providers.superfluid.name", toml_str("Superfluid")),
204        ("model_providers.superfluid.base_url", toml_str(&format!("{}/v1", t.base))),
205        ("model_providers.superfluid.wire_api", toml_str("responses")),
206        ("model_providers.superfluid.env_key", toml_str("SUPERFLUID_LAUNCH_KEY")),
207        ("model_catalog_json", toml_str(&catalog.to_string_lossy())),
208        ("web_search", toml_str("disabled")),
209    ] {
210        args.push("-c".to_string());
211        args.push(format!("{k}={v}"));
212    }
213    args.push("-m".into());
214    args.push(t.model.clone());
215    args.extend(extra.iter().cloned());
216    Plan {
217        program: "codex".into(),
218        args,
219        env: vec![("SUPERFLUID_LAUNCH_KEY".into(), t.key().to_string())],
220        files: vec![(catalog, text)],
221        ..Plan::default()
222    }
223}
224
225/// pi over Chat Completions, through a provider an extension loaded for this run registers.
226fn pi(t: &Target, extra: &[String]) -> Plan {
227    let ext = state_dir().join("pi-superfluid.ts");
228    let window = t.n_ctx.unwrap_or(FALLBACK_WINDOW);
229    let provider = serde_json::json!({
230        "name": "Superfluid",
231        "baseUrl": format!("{}/v1", t.base),
232        "apiKey": t.key(),
233        "api": "openai-completions",
234        "models": [{
235            "id": t.model,
236            "name": t.model,
237            "reasoning": false,
238            "input": ["text"],
239            "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
240            "contextWindow": window,
241            "maxTokens": window.min(16_384),
242        }],
243    });
244    let text = format!(
245        "// Written by `superfluid launch pi` for one run: the server at {}.\n\
246         export default function (pi: any) {{\n  pi.registerProvider(\"superfluid\", {});\n}}\n",
247        t.base,
248        serde_json::to_string_pretty(&provider).expect("json").replace('\n', "\n  ")
249    );
250    let mut args = vec!["-e".to_string(), ext.to_string_lossy().into_owned()];
251    args.extend(["--provider".into(), "superfluid".into(), "--model".into(), t.model.clone()]);
252    args.extend(extra.iter().cloned());
253    Plan { program: "pi".into(), args, files: vec![(ext, text)], ..Plan::default() }
254}
255
256/// OpenCode over Chat Completions, through a provider passed in `OPENCODE_CONFIG_CONTENT`.
257fn opencode(t: &Target, extra: &[String]) -> Plan {
258    let window = t.n_ctx.unwrap_or(FALLBACK_WINDOW);
259    let mut models = serde_json::Map::new();
260    models.insert(
261        t.model.clone(),
262        serde_json::json!({"name": t.model, "limit": {"context": window, "output": window.min(16_384)}}),
263    );
264    let config = serde_json::json!({
265        "$schema": "https://opencode.ai/config.json",
266        "provider": {"superfluid": {
267            "npm": "@ai-sdk/openai-compatible",
268            "name": "Superfluid",
269            "options": {"baseURL": format!("{}/v1", t.base), "apiKey": t.key()},
270            "models": models,
271        }},
272        "model": format!("superfluid/{}", t.model),
273    });
274    Plan {
275        program: "opencode".into(),
276        args: extra.to_vec(),
277        env: vec![("OPENCODE_CONFIG_CONTENT".into(), config.to_string())],
278        ..Plan::default()
279    }
280}
281
282/// Hermes over Chat Completions, through its `custom` provider at `CUSTOM_BASE_URL`; a key is
283/// sent only beside an `OPENAI_BASE_URL` naming this server.
284fn hermes(t: &Target, extra: &[String]) -> Plan {
285    let v1 = format!("{}/v1", t.base);
286    let mut env = vec![("CUSTOM_BASE_URL".to_string(), v1.clone())];
287    if let Some(key) = &t.api_key {
288        env.push(("OPENAI_BASE_URL".into(), v1));
289        env.push(("OPENAI_API_KEY".into(), key.clone()));
290    }
291    let mut notes = Vec::new();
292    if let Some(n) = t.n_ctx.filter(|&n| n < 64_000) {
293        notes.push(format!(
294            "the server's context window is {n} tokens; Hermes refuses a model with less than 64000. \
295             Restart the server with --max-context 65536 or more."
296        ));
297    }
298    let mut args = vec!["--provider".to_string(), "custom".into(), "-m".into(), t.model.clone()];
299    args.extend(extra.iter().cloned());
300    Plan { program: "hermes".into(), args, env, notes, ..Plan::default() }
301}
302
303/// Cline over Chat Completions, through a provider settings file of launch's own named by
304/// `CLINE_PROVIDER_SETTINGS_PATH`. It needs an `updatedAt`, and no `-P`/`-m` (saved over the file).
305fn cline(t: &Target, extra: &[String]) -> Plan {
306    // RFC 3339 to the millisecond, as Cline writes it.
307    let mut updated = crate::ollama::timestamp(std::time::SystemTime::now());
308    updated.truncate(23);
309    updated.push('Z');
310    let path = state_dir().join("cline-providers.json");
311    let settings = serde_json::json!({
312        "version": 1,
313        "lastUsedProvider": "openai-compatible",
314        "modes": {},
315        "providers": {"openai-compatible": {
316            "settings": {
317                "provider": "openai-compatible",
318                "apiKey": t.key(),
319                "model": t.model,
320                "baseUrl": format!("{}/v1", t.base),
321            },
322            "updatedAt": updated,
323            "tokenSource": "manual",
324        }},
325    });
326    let text = serde_json::to_string_pretty(&settings).expect("json") + "\n";
327    Plan {
328        program: "cline".into(),
329        args: extra.to_vec(),
330        env: vec![("CLINE_PROVIDER_SETTINGS_PATH".into(), path.to_string_lossy().into_owned())],
331        files: vec![(path, text)],
332        ..Plan::default()
333    }
334}
335
336/// The OpenClaw profile launch keeps, apart from the user's own.
337const OPENCLAW_PROFILE: &str = "superfluid";
338
339/// OpenClaw over Chat Completions: the provider and default model are set in launch's own profile
340/// with `config set`, then `tui --local` opens (or the arguments after `--`).
341fn openclaw(t: &Target, extra: &[String]) -> Plan {
342    let window = t.n_ctx.unwrap_or(FALLBACK_WINDOW);
343    let provider = serde_json::json!({
344        "baseUrl": format!("{}/v1", t.base),
345        "apiKey": t.key(),
346        "api": "openai-completions",
347        "models": [{
348            "id": t.model,
349            "name": t.model,
350            "input": ["text"],
351            "reasoning": false,
352            "contextWindow": window,
353            "maxTokens": window.min(16_384),
354            "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
355        }],
356    });
357    let profile = |rest: &[&str]| -> Vec<String> {
358        ["--profile", OPENCLAW_PROFILE].iter().chain(rest).map(|s| s.to_string()).collect()
359    };
360    let mut before = Vec::new();
361    let config = std::env::home_dir().map(|h| h.join(format!(".openclaw-{OPENCLAW_PROFILE}")).join("openclaw.json"));
362    if !config.is_some_and(|c| c.exists()) {
363        before.push(profile(&["setup", "--baseline"]));
364    }
365    before.push(profile(&["config", "set", "models.providers.superfluid", &provider.to_string(), "--strict-json"]));
366    before.push(profile(&["config", "set", "agents.defaults.model.primary", &format!("superfluid/{}", t.model)]));
367    let mut args = profile(&[]);
368    if extra.is_empty() {
369        args.extend(["tui".to_string(), "--local".to_string()]);
370    } else {
371        args.extend(extra.iter().cloned());
372    }
373    Plan { program: "openclaw".into(), args, before, ..Plan::default() }
374}
375
376/// The `ollama` command line with `OLLAMA_HOST` set to the server: `run <model>`, or the
377/// arguments after `--`.
378fn ollama(t: &Target, extra: &[String]) -> Plan {
379    let host = t.base.trim_start_matches("http://").to_string();
380    let args = if extra.is_empty() { vec!["run".to_string(), t.model.clone()] } else { extra.to_vec() };
381    Plan { program: "ollama".into(), args, env: vec![("OLLAMA_HOST".into(), host)], ..Plan::default() }
382}
383
384pub fn find(name: &str) -> Option<&'static Agent> {
385    AGENTS.iter().find(|a| a.name == name || a.aliases.contains(&name))
386}
387
388pub const HELP: &str = "Run a coding agent against a local model.
389
390Usage: superfluid launch <agent> [options] [-- <agent arguments>...]
391       superfluid launch            List the agents and whether each is installed
392
393  Agents: claude, codex, pi, opencode, hermes, cline, openclaw, ollama (its CLI's chat).
394
395  Points the agent at the server on --http. When none answers there, starts
396  `superfluid serve <model>` in the background first (its log goes to
397  $SUPERFLUID_HOME/logs/serve.log) and leaves it running; `superfluid stop` stops it.
398  The model is --model, or the one launch last started a server with.
399
400Options:
401  --model <path|org/model[:tag]>   The model to serve, and the one the agent uses on a server
402                                   that serves several [default: the server's first]
403  --http <addr:port>               The server [default: $SUPERFLUID_HTTP, else 127.0.0.1:8453]
404  --api-key <key>                  The server's key, for one behind --api-key
405                                   [default: $SUPERFLUID_API_KEY]
406  --print                          Print what would run instead of running it
407
408Examples:
409  superfluid launch claude --model unsloth/Qwen3.8-27B-GGUF:UD-Q4_K_M
410  superfluid launch claude -- --continue
411";
412
413pub const STOP_HELP: &str = "Stop the server `superfluid launch` started.
414
415Usage: superfluid stop
416
417  Sends it SIGTERM and waits for it to finish what is in flight. A server you started
418  yourself with `superfluid serve` is yours to stop.
419";
420
421#[derive(Debug, Default, PartialEq)]
422struct Args {
423    agent: Option<String>,
424    model: Option<String>,
425    http: Option<String>,
426    api_key: Option<String>,
427    print: bool,
428    extra: Vec<String>,
429}
430
431fn parse(args: &[String]) -> Result<Args, String> {
432    let mut out = Args::default();
433    let mut it = args.iter();
434    while let Some(a) = it.next() {
435        let (flag, inline) = match a.split_once('=') {
436            Some((f, v)) if f.starts_with("--") => (f, Some(v.to_string())),
437            _ => (a.as_str(), None),
438        };
439        let mut value = |flag: &str| inline.clone().or_else(|| it.next().cloned()).ok_or(format!("{flag} needs a value"));
440        match flag {
441            "--" => {
442                out.extra = it.cloned().collect();
443                break;
444            }
445            "--model" => out.model = Some(value(flag)?),
446            "--http" => out.http = Some(value(flag)?),
447            "--api-key" => out.api_key = Some(value(flag)?),
448            "--print" => out.print = true,
449            f if f.starts_with('-') => {
450                let known = ["--model", "--http", "--api-key", "--print"];
451                return Err(match crate::cli::suggest(f, known) {
452                    Some(s) => format!("unknown flag {f} (did you mean {s}?); pass the agent's own flags after --"),
453                    None => format!("unknown flag {f}; pass the agent's own flags after --"),
454                });
455            }
456            name if out.agent.is_none() => out.agent = Some(name.to_string()),
457            other => return Err(format!("unexpected argument {other}; pass the agent's own arguments after --")),
458        }
459    }
460    Ok(out)
461}
462
463/// A served model, as `/v1/models` lists it.
464#[derive(Debug, Clone, PartialEq)]
465struct Served {
466    id: String,
467    n_ctx: Option<u64>,
468}
469
470enum Probe {
471    Up(Vec<Served>),
472    Unauthorized,
473    Down,
474}
475
476fn probe(base: &str, api_key: Option<&str>) -> Probe {
477    let agent = ureq::Agent::config_builder()
478        .timeout_global(Some(Duration::from_secs(3)))
479        .http_status_as_error(false)
480        .build()
481        .new_agent();
482    let mut req = agent.get(format!("{base}/v1/models"));
483    if let Some(key) = api_key {
484        req = req.header("authorization", &format!("Bearer {key}"));
485    }
486    let Ok(mut resp) = req.call() else { return Probe::Down };
487    match resp.status().as_u16() {
488        200 => {}
489        401 | 403 => return Probe::Unauthorized,
490        _ => return Probe::Down,
491    }
492    let Ok(body) = resp.body_mut().read_to_string() else { return Probe::Down };
493    match parse_models(&body) {
494        Some(models) => Probe::Up(models),
495        None => Probe::Down,
496    }
497}
498
499fn parse_models(body: &str) -> Option<Vec<Served>> {
500    let v: serde_json::Value = serde_json::from_str(body).ok()?;
501    let data = v.get("data")?.as_array()?;
502    Some(
503        data.iter()
504            .filter_map(|m| {
505                Some(Served {
506                    id: m.get("id")?.as_str()?.to_string(),
507                    n_ctx: m.pointer("/meta/n_ctx").and_then(serde_json::Value::as_u64),
508                })
509            })
510            .collect(),
511    )
512}
513
514/// The served model `wanted` names, by id or by its file or repo name; with nothing wanted, the
515/// server's first.
516fn pick<'a>(served: &'a [Served], wanted: Option<&str>) -> Option<&'a Served> {
517    let Some(wanted) = wanted else { return served.first() };
518    if let Some(m) = served.iter().find(|m| m.id == wanted) {
519        return Some(m);
520    }
521    let stem = |s: &str| {
522        let s = s.split_once(':').map_or(s, |(s, _)| s);
523        let s = s.rsplit('/').next().unwrap_or(s);
524        s.strip_suffix(".gguf").unwrap_or(s).to_ascii_lowercase()
525    };
526    let w = stem(wanted);
527    served.iter().find(|m| stem(&m.id) == w)
528}
529
530/// The id `serve <model>` lists the model under.
531fn served_id(model: &str) -> String {
532    if crate::pulls::looks_like_id(model) {
533        crate::pulls::served_name(model)
534    } else {
535        crate::registry::model_id_for(Path::new(model))
536    }
537}
538
539fn on_path(binary: &str) -> Option<PathBuf> {
540    let path = std::env::var_os("PATH")?;
541    std::env::split_paths(&path).map(|d| d.join(binary)).find(|p| {
542        use std::os::unix::fs::PermissionsExt;
543        std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
544    })
545}
546
547fn fail(msg: impl std::fmt::Display) -> ! {
548    eprintln!("superfluid launch: {msg}");
549    std::process::exit(1);
550}
551
552/// `$SUPERFLUID_HOME/launch/`: the model launch last started a server with, and that server.
553fn state_dir() -> PathBuf {
554    crate::runtimes::default_home().join("launch")
555}
556
557/// The server `launch` started: its pid and address, one per line.
558fn server_file() -> PathBuf {
559    state_dir().join("server")
560}
561
562fn read_server() -> Option<(i32, String)> {
563    let text = std::fs::read_to_string(server_file()).ok()?;
564    let mut lines = text.lines();
565    let pid = lines.next()?.trim().parse().ok()?;
566    let addr = lines.next()?.trim().to_string();
567    alive(pid).then_some((pid, addr))
568}
569
570fn alive(pid: i32) -> bool {
571    // SAFETY: signal 0 checks for the process without sending anything.
572    pid > 0 && unsafe { libc::kill(pid, 0) } == 0
573}
574
575pub fn run(args: &[String]) {
576    let a = parse(args).unwrap_or_else(|e| {
577        eprintln!("superfluid launch: {e}\n\n{HELP}");
578        std::process::exit(2);
579    });
580    let Some(name) = a.agent.as_deref() else {
581        list();
582        return;
583    };
584    let agent = find(name).unwrap_or_else(|| {
585        let names = AGENTS.iter().map(|a| a.name);
586        match crate::cli::suggest(name, names) {
587            Some(s) => fail(format!("no agent called {name} (did you mean {s}?); `superfluid launch` lists them")),
588            None => fail(format!("no agent called {name}; `superfluid launch` lists them")),
589        }
590    });
591    if !a.print && on_path(agent.binary).is_none() {
592        fail(format!("{} is not installed ({} not on PATH). Install it with:\n  {}", agent.title, agent.binary, agent.install));
593    }
594    let addr = a
595        .http
596        .clone()
597        .or_else(|| std::env::var("SUPERFLUID_HTTP").ok().filter(|s| !s.is_empty()))
598        .unwrap_or_else(|| DEFAULT_HTTP.to_string());
599    let addr = addr.trim_start_matches("http://").trim_end_matches('/').to_string();
600    let base = format!("http://{addr}");
601    let api_key = a.api_key.clone().or_else(|| std::env::var("SUPERFLUID_API_KEY").ok().filter(|s| !s.is_empty()));
602    let mut started = false;
603    let served = match probe(&base, api_key.as_deref()) {
604        Probe::Up(served) => served,
605        Probe::Unauthorized => fail(format!("the server at {addr} wants a key: pass --api-key or set SUPERFLUID_API_KEY")),
606        Probe::Down if a.print => {
607            let id = a.model.clone().or_else(remembered).map_or_else(|| "<model>".into(), |m| served_id(&m));
608            vec![Served { id, n_ctx: None }]
609        }
610        Probe::Down => {
611            let model = a.model.clone().or_else(remembered).unwrap_or_else(|| {
612                fail(format!(
613                    "no server at {addr}, and no model to start one with. Pass one:\n  \
614                     superfluid launch {} --model <path|org/model[:tag]>",
615                    agent.name
616                ))
617            });
618            started = true;
619            start_server(&model, &addr, api_key.as_deref())
620        }
621    };
622    // A server launch started serves the model it was started with, under whatever id.
623    let wanted = if started { None } else { a.model.as_deref() };
624    let model = pick(&served, wanted).unwrap_or_else(|| {
625        let ids: Vec<&str> = served.iter().map(|m| m.id.as_str()).collect();
626        fail(format!(
627            "the server at {addr} does not serve {}; it serves: {}",
628            a.model.as_deref().unwrap_or("any model"),
629            if ids.is_empty() { "nothing".to_string() } else { ids.join(", ") }
630        ))
631    });
632    let target = Target { base, model: model.id.clone(), n_ctx: model.n_ctx, api_key };
633    let plan = (agent.plan)(&target, &a.extra);
634    if a.print {
635        print!("{}", render(&plan));
636        return;
637    }
638    for (path, text) in &plan.files {
639        if let Err(e) = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|()| std::fs::write(path, text)) {
640            fail(format!("could not write {}: {e}", path.display()));
641        }
642    }
643    for args in &plan.before {
644        let out = std::process::Command::new(&plan.program)
645            .args(args)
646            .stdin(std::process::Stdio::null())
647            .output()
648            .unwrap_or_else(|e| fail(format!("could not run {}: {e}", plan.program)));
649        if !out.status.success() {
650            let err = String::from_utf8_lossy(&out.stderr);
651            let tail: Vec<&str> = err.lines().rev().take(10).collect();
652            fail(format!(
653                "`{} {}` failed ({}):\n{}",
654                plan.program,
655                args.join(" "),
656                out.status,
657                tail.into_iter().rev().collect::<Vec<_>>().join("\n")
658            ));
659        }
660    }
661    for note in &plan.notes {
662        eprintln!("superfluid launch: {note}");
663    }
664    eprintln!("superfluid launch: {} on {} at {}", agent.title, target.model, target.base);
665    exec(&plan);
666}
667
668fn list() {
669    println!("Agents (superfluid launch <agent>):\n");
670    for a in AGENTS {
671        let state = if on_path(a.binary).is_some() { "installed".to_string() } else { format!("install: {}", a.install) };
672        println!("  {:<10} {:<14} {state}", a.name, a.title);
673    }
674    match read_server() {
675        Some((pid, addr)) => println!("\nServer started by launch: {addr} (pid {pid}); `superfluid stop` stops it."),
676        None => {
677            if let Some(m) = remembered() {
678                println!("\nWith no server running, launch starts one with {m}.");
679            }
680        }
681    }
682}
683
684fn remembered() -> Option<String> {
685    std::fs::read_to_string(state_dir().join("model")).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
686}
687
688/// Starts `superfluid serve <model>` in its own session, so it outlives the agent and the
689/// terminal, and waits until it lists its models.
690fn start_server(model: &str, addr: &str, api_key: Option<&str>) -> Vec<Served> {
691    use std::os::unix::process::CommandExt;
692    let exe = std::env::current_exe().unwrap_or_else(|e| fail(format!("could not find this program: {e}")));
693    let logs = crate::runtimes::default_home().join("logs");
694    let log = logs.join("serve.log");
695    if let Err(e) = std::fs::create_dir_all(&logs).and_then(|()| std::fs::create_dir_all(state_dir())) {
696        fail(format!("could not create {}: {e}", logs.display()));
697    }
698    let start_len = std::fs::metadata(&log).map(|m| m.len()).unwrap_or(0);
699    let mut cmd = std::process::Command::new(exe);
700    cmd.arg("serve").arg(model).args(["--http", addr, "--no-tui", "--log-file"]).arg(&log);
701    if let Some(key) = api_key {
702        cmd.args(["--api-key", key]);
703    }
704    let stdout = std::fs::OpenOptions::new().create(true).append(true).open(&log);
705    cmd.stdin(std::process::Stdio::null())
706        .stdout(stdout.map(std::process::Stdio::from).unwrap_or_else(|_| std::process::Stdio::null()))
707        .stderr(std::process::Stdio::null());
708    // SAFETY: setsid is async-signal-safe and touches only the child.
709    unsafe {
710        cmd.pre_exec(|| {
711            libc::setsid();
712            Ok(())
713        });
714    }
715    let mut child = cmd.spawn().unwrap_or_else(|e| fail(format!("could not start the server: {e}")));
716    let pid = child.id() as i32;
717    let _ = std::fs::write(server_file(), format!("{pid}\n{addr}\n"));
718    let _ = std::fs::write(state_dir().join("model"), format!("{model}\n"));
719    eprintln!("superfluid launch: starting `superfluid serve {model}` on {addr} (log: {})", log.display());
720    let base = format!("http://{addr}");
721    let started = Instant::now();
722    let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
723    loop {
724        if let Ok(Some(status)) = child.try_wait() {
725            if tty {
726                eprint!("\r\x1b[2K");
727            }
728            let _ = std::fs::remove_file(server_file());
729            fail(format!("the server exited ({status}) before it was ready:\n{}", tail(&log, start_len, 15)));
730        }
731        if let Probe::Up(served) = probe(&base, api_key) {
732            if tty {
733                eprint!("\r\x1b[2K");
734            }
735            eprintln!("superfluid launch: server ready in {:.0} s", started.elapsed().as_secs_f64());
736            return served;
737        }
738        if tty {
739            let last = tail(&log, start_len, 1);
740            let last: String = last.trim().chars().take(90).collect();
741            eprint!("\r\x1b[2K  {:>4.0} s  {last}", started.elapsed().as_secs_f64());
742        }
743        std::thread::sleep(Duration::from_millis(500));
744    }
745}
746
747/// The last `n` lines `log` gained past byte `from`.
748fn tail(log: &Path, from: u64, n: usize) -> String {
749    let Ok(bytes) = std::fs::read(log) else { return String::new() };
750    let text = String::from_utf8_lossy(bytes.get(from as usize..).unwrap_or_default());
751    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
752    lines[lines.len().saturating_sub(n)..].join("\n")
753}
754
755fn exec(plan: &Plan) -> ! {
756    use std::os::unix::process::CommandExt;
757    let mut cmd = std::process::Command::new(&plan.program);
758    cmd.args(&plan.args);
759    for k in &plan.env_remove {
760        cmd.env_remove(k);
761    }
762    cmd.envs(plan.env.iter().map(|(k, v)| (k, v)));
763    for (k, v) in &plan.env_default {
764        if std::env::var_os(k).is_none() {
765            cmd.env(k, v);
766        }
767    }
768    let err = cmd.exec();
769    fail(format!("could not run {}: {err}", plan.program))
770}
771
772/// The plan as a shell command line, for `--print`.
773pub fn render(plan: &Plan) -> String {
774    let mut out = String::new();
775    for k in &plan.env_remove {
776        out.push_str(&format!("unset {k}\n"));
777    }
778    for args in &plan.before {
779        let line: Vec<String> = std::iter::once(&plan.program).chain(args).map(|a| quote(a)).collect();
780        out.push_str(&line.join(" "));
781        out.push('\n');
782    }
783    for (path, text) in &plan.files {
784        out.push_str(&format!("# {}:\n{}", path.display(), text.lines().map(|l| format!("#   {l}\n")).collect::<String>()));
785    }
786    for (k, v) in &plan.env_default {
787        out.push_str(&format!("export {k}=\"${{{k}:-{v}}}\"\n"));
788    }
789    let mut line: Vec<String> = plan.env.iter().map(|(k, v)| format!("{k}={}", quote(v))).collect();
790    line.push(quote(&plan.program));
791    line.extend(plan.args.iter().map(|a| quote(a)));
792    out.push_str(&line.join(" \\\n  "));
793    out.push('\n');
794    out
795}
796
797fn quote(s: &str) -> String {
798    if !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || "-_./:=@,+%".contains(c)) {
799        s.to_string()
800    } else {
801        format!("'{}'", s.replace('\'', r"'\''"))
802    }
803}
804
805pub fn stop(args: &[String]) {
806    if let Some(a) = args.first() {
807        eprintln!("superfluid stop: unexpected argument {a}\n\n{STOP_HELP}");
808        std::process::exit(2);
809    }
810    let Some((pid, addr)) = read_server() else {
811        let _ = std::fs::remove_file(server_file());
812        eprintln!("superfluid stop: no server started by `superfluid launch` is running");
813        std::process::exit(1);
814    };
815    // SAFETY: pid is a live process launch started (read_server checked it).
816    unsafe { libc::kill(pid, libc::SIGTERM) };
817    let started = Instant::now();
818    while alive(pid) && started.elapsed() < Duration::from_secs(90) {
819        std::thread::sleep(Duration::from_millis(200));
820    }
821    if alive(pid) {
822        eprintln!("superfluid stop: the server on {addr} (pid {pid}) is still finishing; it got SIGTERM");
823        std::process::exit(1);
824    }
825    let _ = std::fs::remove_file(server_file());
826    eprintln!("superfluid stop: stopped the server on {addr}");
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832
833    fn strings(v: &[&str]) -> Vec<String> {
834        v.iter().map(|s| s.to_string()).collect()
835    }
836
837    fn target(n_ctx: Option<u64>) -> Target {
838        Target { base: "http://127.0.0.1:8453".into(), model: "Qwen3.6-27B-Q4_K_M".into(), n_ctx, api_key: None }
839    }
840
841    fn env<'a>(p: &'a Plan, k: &str) -> Option<&'a str> {
842        p.env.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str())
843    }
844
845    #[test]
846    fn flags_go_to_launch_and_everything_after_the_dashes_to_the_agent() {
847        let a = parse(&strings(&["claude", "--model=m.gguf", "--http", "127.0.0.1:9000", "--", "--continue", "-p", "hi"])).unwrap();
848        assert_eq!(a.agent.as_deref(), Some("claude"));
849        assert_eq!(a.model.as_deref(), Some("m.gguf"));
850        assert_eq!(a.http.as_deref(), Some("127.0.0.1:9000"));
851        assert_eq!(a.extra, strings(&["--continue", "-p", "hi"]));
852        assert!(parse(&strings(&["claude", "--continue"])).unwrap_err().contains("after --"));
853        assert!(parse(&strings(&["claude", "--modle", "x"])).unwrap_err().contains("--model"));
854        assert!(parse(&strings(&["claude", "extra"])).is_err());
855        assert_eq!(parse(&[]).unwrap(), Args::default());
856    }
857
858    #[test]
859    fn claude_code_is_pointed_at_the_server_for_every_model_tier() {
860        let p = claude(&target(Some(262_144)), &strings(&["--continue"]));
861        assert_eq!(p.program, "claude");
862        assert_eq!(p.args, strings(&["--continue"]));
863        assert_eq!(env(&p, "ANTHROPIC_BASE_URL"), Some("http://127.0.0.1:8453"));
864        assert_eq!(env(&p, "ANTHROPIC_AUTH_TOKEN"), Some("superfluid"));
865        for k in ["ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"] {
866            assert_eq!(env(&p, k), Some("Qwen3.6-27B-Q4_K_M"), "{k}");
867        }
868        assert_eq!(env(&p, "CLAUDE_CODE_AUTO_COMPACT_WINDOW"), None, "a window past 200k needs no cap");
869        assert_eq!(env(&p, "CLAUDE_CODE_MAX_CONTEXT_TOKENS"), Some("262144"));
870        assert_eq!(env(&p, "CLAUDE_CODE_ATTRIBUTION_HEADER"), Some("0"));
871        assert_eq!(p.env_default, vec![("CLAUDE_CODE_AUTO_MODE_SERVER".to_string(), "0".to_string())]);
872        assert_eq!(p.env_remove, strings(&["ANTHROPIC_API_KEY"]));
873        assert!(p.notes.is_empty());
874    }
875
876    #[test]
877    fn a_small_window_makes_claude_code_compact_sooner_and_a_tiny_one_is_called_out() {
878        let p = claude(&target(Some(65_536)), &[]);
879        assert_eq!(env(&p, "CLAUDE_CODE_AUTO_COMPACT_WINDOW"), Some("65536"));
880        assert!(p.notes.is_empty());
881        let p = claude(&target(Some(8192)), &[]);
882        assert!(p.notes[0].contains("--max-context"), "{:?}", p.notes);
883        let p = claude(&Target { api_key: Some("k1".into()), ..target(None) }, &[]);
884        assert_eq!(env(&p, "ANTHROPIC_AUTH_TOKEN"), Some("k1"));
885    }
886
887    #[test]
888    fn codex_gets_a_provider_of_its_own_and_a_catalog_with_the_window() {
889        let p = codex(&target(Some(131_072)), &strings(&["exec", "hi"]));
890        assert_eq!(p.program, "codex");
891        let joined = p.args.join(" ");
892        assert!(joined.contains(r#"model_provider="superfluid""#), "{joined}");
893        assert!(joined.contains(r#"model_providers.superfluid.base_url="http://127.0.0.1:8453/v1""#), "{joined}");
894        assert!(joined.contains(r#"model_providers.superfluid.wire_api="responses""#), "{joined}");
895        assert!(joined.contains(r#"web_search="disabled""#), "{joined}");
896        assert!(joined.ends_with("-m Qwen3.6-27B-Q4_K_M exec hi"), "{joined}");
897        assert_eq!(env(&p, "SUPERFLUID_LAUNCH_KEY"), Some("superfluid"));
898        let (path, text) = &p.files[0];
899        assert!(joined.contains(&path.to_string_lossy().to_string()));
900        let catalog: serde_json::Value = serde_json::from_str(text).unwrap();
901        assert_eq!(catalog["models"][0]["slug"], "Qwen3.6-27B-Q4_K_M");
902        assert_eq!(catalog["models"][0]["context_window"], 131_072);
903        assert!(catalog["models"][0].get("apply_patch_tool_type").is_none());
904    }
905
906    #[test]
907    fn pi_loads_a_one_run_extension_that_registers_the_server() {
908        let p = pi(&target(Some(262_144)), &strings(&["-p", "hi"]));
909        let (path, text) = &p.files[0];
910        assert_eq!(p.args[..2], [String::from("-e"), path.to_string_lossy().into_owned()]);
911        assert_eq!(p.args[2..], strings(&["--provider", "superfluid", "--model", "Qwen3.6-27B-Q4_K_M", "-p", "hi"]));
912        assert!(text.contains(r#"pi.registerProvider("superfluid", {"#), "{text}");
913        assert!(text.contains(r#""baseUrl": "http://127.0.0.1:8453/v1""#), "{text}");
914        assert!(text.contains(r#""contextWindow": 262144"#) && text.contains(r#""maxTokens": 16384"#), "{text}");
915    }
916
917    #[test]
918    fn opencode_gets_a_provider_in_its_per_run_config() {
919        let p = opencode(&target(Some(65_536)), &strings(&["run", "hi"]));
920        assert_eq!(p.args, strings(&["run", "hi"]));
921        let config: serde_json::Value = serde_json::from_str(env(&p, "OPENCODE_CONFIG_CONTENT").unwrap()).unwrap();
922        assert_eq!(config["model"], "superfluid/Qwen3.6-27B-Q4_K_M");
923        let provider = &config["provider"]["superfluid"];
924        assert_eq!(provider["npm"], "@ai-sdk/openai-compatible");
925        assert_eq!(provider["options"]["baseURL"], "http://127.0.0.1:8453/v1");
926        assert_eq!(provider["models"]["Qwen3.6-27B-Q4_K_M"]["limit"]["context"], 65_536);
927    }
928
929    #[test]
930    fn hermes_uses_its_custom_provider_and_a_key_only_for_this_host() {
931        let p = hermes(&target(Some(262_144)), &strings(&["-z", "hi"]));
932        assert_eq!(p.args, strings(&["--provider", "custom", "-m", "Qwen3.6-27B-Q4_K_M", "-z", "hi"]));
933        assert_eq!(env(&p, "CUSTOM_BASE_URL"), Some("http://127.0.0.1:8453/v1"));
934        assert_eq!(env(&p, "OPENAI_API_KEY"), None);
935        let p = hermes(&Target { api_key: Some("k1".into()), ..target(Some(32_768)) }, &[]);
936        assert_eq!((env(&p, "OPENAI_BASE_URL"), env(&p, "OPENAI_API_KEY")), (Some("http://127.0.0.1:8453/v1"), Some("k1")));
937        assert!(p.notes[0].contains("64000"), "{:?}", p.notes);
938    }
939
940    #[test]
941    fn cline_reads_a_provider_settings_file_of_launchs_own() {
942        let p = cline(&target(None), &strings(&["-y", "hi"]));
943        assert_eq!(p.args, strings(&["-y", "hi"]));
944        let (path, text) = &p.files[0];
945        assert_eq!(env(&p, "CLINE_PROVIDER_SETTINGS_PATH"), Some(path.to_string_lossy().as_ref()));
946        let v: serde_json::Value = serde_json::from_str(text).unwrap();
947        assert_eq!(v["lastUsedProvider"], "openai-compatible");
948        let s = &v["providers"]["openai-compatible"]["settings"];
949        assert_eq!((s["model"].as_str(), s["baseUrl"].as_str()), (Some("Qwen3.6-27B-Q4_K_M"), Some("http://127.0.0.1:8453/v1")));
950        let updated = v["providers"]["openai-compatible"]["updatedAt"].as_str().unwrap_or_default();
951        assert!(updated.len() == 24 && updated.ends_with('Z') && updated.as_bytes()[19] == b'.', "{updated}");
952    }
953
954    #[test]
955    fn openclaw_is_set_up_in_a_profile_of_its_own_and_opens_the_local_terminal_ui() {
956        let p = openclaw(&target(Some(262_144)), &[]);
957        assert_eq!(p.args, strings(&["--profile", "superfluid", "tui", "--local"]));
958        let sets: Vec<&Vec<String>> = p.before.iter().filter(|b| b.get(2).map(String::as_str) == Some("config")).collect();
959        assert_eq!(sets.len(), 2);
960        assert!(p.before.iter().all(|b| b[..2] == strings(&["--profile", "superfluid"])));
961        let provider: serde_json::Value = serde_json::from_str(&sets[0][5]).unwrap();
962        assert_eq!(sets[0][4], "models.providers.superfluid");
963        assert_eq!(provider["baseUrl"], "http://127.0.0.1:8453/v1");
964        assert_eq!(provider["models"][0]["contextWindow"], 262_144);
965        assert_eq!(sets[1][4..], strings(&["agents.defaults.model.primary", "superfluid/Qwen3.6-27B-Q4_K_M"]));
966        let p = openclaw(&target(None), &strings(&["agent", "--local", "-m", "hi"]));
967        assert_eq!(p.args, strings(&["--profile", "superfluid", "agent", "--local", "-m", "hi"]));
968    }
969
970    #[test]
971    fn the_ollama_cli_is_pointed_at_the_server_and_chats_with_the_model() {
972        let p = ollama(&target(None), &[]);
973        assert_eq!(p.args, strings(&["run", "Qwen3.6-27B-Q4_K_M"]));
974        assert_eq!(env(&p, "OLLAMA_HOST"), Some("127.0.0.1:8453"));
975        assert_eq!(ollama(&target(None), &strings(&["ps"])).args, strings(&["ps"]));
976    }
977
978    #[test]
979    fn a_model_is_found_by_id_or_by_the_name_it_was_served_from() {
980        let served = vec![
981            Served { id: "Qwen3.6-27B-Q4_K_M".into(), n_ctx: Some(262_144) },
982            Served { id: "gemma-4-E4B".into(), n_ctx: None },
983        ];
984        assert_eq!(pick(&served, None).unwrap().id, "Qwen3.6-27B-Q4_K_M");
985        assert_eq!(pick(&served, Some("gemma-4-E4B")).unwrap().id, "gemma-4-E4B");
986        assert_eq!(pick(&served, Some("./models/Qwen3.6-27B-Q4_K_M.gguf")).unwrap().id, "Qwen3.6-27B-Q4_K_M");
987        assert_eq!(pick(&served, Some("google/gemma-4-E4B")).unwrap().id, "gemma-4-E4B");
988        assert_eq!(pick(&[Served { id: "unsloth/Qwen3.8-27B-GGUF".into(), n_ctx: None }], Some("unsloth/Qwen3.8-27B-GGUF:UD-Q4_K_M")).unwrap().id, "unsloth/Qwen3.8-27B-GGUF");
989        assert!(pick(&served, Some("llama-3")).is_none());
990        assert!(pick(&served, Some("Qwen3.6")).is_none(), "a prefix is not a name");
991        assert!(pick(&[], None).is_none());
992    }
993
994    #[test]
995    fn models_and_their_windows_are_read_from_the_model_list() {
996        let body = r#"{"object":"list","data":[{"id":"a","meta":{"n_ctx":131072}},{"id":"b","meta":{}}]}"#;
997        assert_eq!(
998            parse_models(body).unwrap(),
999            vec![Served { id: "a".into(), n_ctx: Some(131_072) }, Served { id: "b".into(), n_ctx: None }]
1000        );
1001        assert!(parse_models("<html>").is_none());
1002    }
1003
1004    #[test]
1005    fn print_renders_a_command_a_shell_runs_as_is() {
1006        let p = Plan {
1007            program: "claude".into(),
1008            args: strings(&["-p", "it's here"]),
1009            env: vec![("A".into(), "http://x:1".into()), ("B".into(), "two words".into())],
1010            env_remove: strings(&["K"]),
1011            ..Plan::default()
1012        };
1013        assert_eq!(render(&p), "unset K\nA=http://x:1 \\\n  B='two words' \\\n  claude \\\n  -p \\\n  'it'\\''s here'\n");
1014    }
1015
1016    #[test]
1017    fn every_agent_is_found_by_name_and_alias() {
1018        for a in AGENTS {
1019            assert!(find(a.name).is_some_and(|f| f.name == a.name));
1020            for al in a.aliases {
1021                assert!(find(al).is_some_and(|f| f.name == a.name));
1022            }
1023        }
1024        assert!(find("nope").is_none());
1025    }
1026}