Skip to main content

netget/server/stdio/
mod.rs

1//! Standard I/O (stdin/stdout/stderr) server implementation.
2//!
3//! Platform: Unix/Linux/macOS only. NetGet itself becomes the child process behind a pipe
4//! (`someprogram | netget ... | otherprogram`): it reads from its own stdin, hands each chunk to
5//! the LLM as a `stdio_input_received` event, and the model decides what to emit on stdout/stderr.
6//!
7//! ## Coexistence (the subtlety)
8//!
9//! This protocol takes over the process's real stdin/stdout, so it is fundamentally incompatible
10//! with two run modes and refuses to start under either:
11//!
12//! - **the interactive TUI** — ratatui owns the terminal; detected via `stdin` being a TTY.
13//! - **`--mcp` stdio** — JSON-RPC owns stdin/stdout; detected via the process argv.
14//!
15//! It is intended for headless / one-shot piped use (`prog | netget --non-interactive '...' | prog`)
16//! or under `--mcp-http`, where stdin is a pipe and tracing logs go to stderr. Only one stdio
17//! server may run per process (an `AtomicBool` claim). See `CLAUDE.md` for the caveat that the
18//! non-interactive status stream currently shares stdout.
19#![cfg(unix)]
20
21pub mod actions;
22
23use anyhow::Result;
24use std::io::IsTerminal;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::Arc;
27use tokio::io::{AsyncReadExt, AsyncWriteExt};
28use tokio::sync::mpsc;
29use tracing::{debug, info};
30
31use crate::llm::action_helper::call_llm;
32use crate::llm::ollama_client::OllamaClient;
33use crate::llm::ActionResult;
34use crate::logging::emit::Log;
35use crate::protocol::Event;
36use crate::server::stdio::actions::{
37    StdioProtocol, STDIO_INPUT_CLOSED_EVENT, STDIO_INPUT_RECEIVED_EVENT, STDIO_STARTED_EVENT,
38};
39use crate::state::app_state::AppState;
40
41/// At most one stdio server may own the process's standard streams at a time.
42static STDIO_CLAIMED: AtomicBool = AtomicBool::new(false);
43
44/// Releases the stdio claim when the read task ends (including on abort).
45struct StdioClaimGuard;
46
47impl Drop for StdioClaimGuard {
48    fn drop(&mut self) {
49        STDIO_CLAIMED.store(false, Ordering::SeqCst);
50    }
51}
52
53/// True when this process was launched as an MCP stdio server (`--mcp` / `--mcp-stdio`), in which
54/// case JSON-RPC owns stdin/stdout and a stdio protocol server must not contend for them.
55/// `--mcp-http` is a different flag and does not match.
56fn running_as_mcp_stdio() -> bool {
57    std::env::args().any(|a| a == "--mcp" || a == "--mcp-stdio")
58}
59
60/// Standard I/O server.
61pub struct StdioServer;
62
63impl StdioServer {
64    /// Claim the process stdio and spawn the read/dispatch loop.
65    ///
66    /// Returns `Err` — so `server_startup` records `ServerStatus::Error` — when the environment
67    /// cannot support a stdio takeover (a TTY/TUI, `--mcp` stdio, or another stdio server already
68    /// running). No half-started state is left behind on refusal.
69    pub async fn spawn_with_llm_actions(
70        send_first: bool,
71        llm_client: OllamaClient,
72        app_state: Arc<AppState>,
73        status_tx: mpsc::UnboundedSender<String>,
74        server_id: crate::state::ServerId,
75    ) -> Result<()> {
76        if std::io::stdin().is_terminal() {
77            anyhow::bail!(
78                "stdio server refuses to run under an interactive terminal: the TUI owns the \
79                 terminal. Use it as a pipe filter (prog | netget --non-interactive '...' | prog) \
80                 or under --mcp-http."
81            );
82        }
83        if running_as_mcp_stdio() {
84            anyhow::bail!(
85                "stdio server cannot run under --mcp (stdio): MCP JSON-RPC already owns this \
86                 process's stdin/stdout. Use --mcp-http, or run NetGet headless as a pipe filter."
87            );
88        }
89        if STDIO_CLAIMED
90            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
91            .is_err()
92        {
93            anyhow::bail!(
94                "another stdio server already owns this process's stdin/stdout; only one is \
95                 possible per process."
96            );
97        }
98        // From here on we own the claim; the guard releases it on task end.
99        let claim = StdioClaimGuard;
100
101        Log::new(Some(&status_tx)).info("stdio server claimed process stdin/stdout");
102
103        let protocol = Arc::new(StdioProtocol::new());
104        let task_registrar = app_state.clone();
105
106        let handle = tokio::spawn(async move {
107            let _claim = claim; // released on return/abort
108
109            if send_first {
110                let event = Event::new(&STDIO_STARTED_EVENT, serde_json::json!({}));
111                let _ = Self::dispatch(
112                    &event,
113                    &llm_client,
114                    &app_state,
115                    server_id,
116                    protocol.as_ref(),
117                    &status_tx,
118                )
119                .await;
120            }
121
122            let mut stdin = tokio::io::stdin();
123            let mut buffer = vec![0u8; 8192];
124
125            loop {
126                match stdin.read(&mut buffer).await {
127                    Ok(0) => {
128                        // EOF: upstream closed the pipe. Let the model emit any final output.
129                        Log::new(Some(&status_tx)).debug("stdio stdin reached EOF");
130                        let event = Event::new(&STDIO_INPUT_CLOSED_EVENT, serde_json::json!({}));
131                        let _ = Self::dispatch(
132                            &event,
133                            &llm_client,
134                            &app_state,
135                            server_id,
136                            protocol.as_ref(),
137                            &status_tx,
138                        )
139                        .await;
140                        break;
141                    }
142                    Ok(n) => {
143                        let data = &buffer[..n];
144                        let (data_str, encoding) = if data
145                            .iter()
146                            .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
147                        {
148                            (String::from_utf8_lossy(data).to_string(), "utf8")
149                        } else {
150                            (hex::encode(data), "hex")
151                        };
152
153                        Log::new(Some(&status_tx))
154                            .debug(format!("stdio read {} bytes from stdin ({})", n, encoding));
155
156                        let event = Event::new(
157                            &STDIO_INPUT_RECEIVED_EVENT,
158                            serde_json::json!({ "data": data_str, "encoding": encoding }),
159                        );
160                        let should_close = Self::dispatch(
161                            &event,
162                            &llm_client,
163                            &app_state,
164                            server_id,
165                            protocol.as_ref(),
166                            &status_tx,
167                        )
168                        .await;
169                        if should_close {
170                            info!("stdio session closed by model (close_stdio)");
171                            break;
172                        }
173                    }
174                    Err(e) => {
175                        Log::new(Some(&status_tx)).error(format!("stdio stdin read error: {}", e));
176                        break;
177                    }
178                }
179            }
180        });
181
182        task_registrar.register_server_task(server_id, handle).await;
183        Ok(())
184    }
185
186    /// Run one LLM round-trip for `event` and act on the results. Returns true if the model asked
187    /// to close the stdio session.
188    async fn dispatch(
189        event: &Event,
190        llm_client: &OllamaClient,
191        app_state: &Arc<AppState>,
192        server_id: crate::state::ServerId,
193        protocol: &StdioProtocol,
194        status_tx: &mpsc::UnboundedSender<String>,
195    ) -> bool {
196        match call_llm(llm_client, app_state, server_id, None, event, protocol).await {
197            Ok(result) => {
198                for msg in result.messages {
199                    let _ = status_tx.send(msg);
200                }
201                let mut should_close = false;
202                let mut wrote = false;
203                for pr in result.protocol_results {
204                    match pr {
205                        ActionResult::Output(bytes) => {
206                            wrote = true;
207                            Self::write_stream(false, &bytes, status_tx).await;
208                        }
209                        ActionResult::Custom { name, data } if name == "stdio_stderr" => {
210                            wrote = true;
211                            if let Some(hex_str) = data.get("hex").and_then(|v| v.as_str()) {
212                                if let Ok(bytes) = hex::decode(hex_str) {
213                                    Self::write_stream(true, &bytes, status_tx).await;
214                                }
215                            }
216                        }
217                        ActionResult::CloseConnection => should_close = true,
218                        _ => {}
219                    }
220                }
221                // The model answering nothing is a real answer for a filter (grep drops a line),
222                // so it emits nothing — but it must stay distinguishable in the log from the
223                // backend having failed, which is the case below.
224                let decision = match (wrote, should_close) {
225                    (_, true) => "model_close",
226                    (true, false) => "model_answer",
227                    (false, false) => "model_silent",
228                };
229                debug!("stdio {} decision={}", event.event_type.id, decision);
230                should_close
231            }
232            Err(e) => {
233                // The backend failed. stdout is the payload stream and stays pristine — writing a
234                // diagnostic there would corrupt the data a downstream process is parsing. fd 2 is
235                // where a Unix filter reports that it could not do its job, so the category goes
236                // there and nothing else does: never the error, which goes to the log alone.
237                let failure = crate::utils::WireFailure::classify(&e);
238                let decision = if failure.is_overloaded() {
239                    "fail_closed_overloaded"
240                } else {
241                    "fail_closed_unavailable"
242                };
243                Log::new(Some(status_tx)).error(format!(
244                    "stdio {} decision={} error: {}",
245                    event.event_type.id, decision, e
246                ));
247                let mut line = String::with_capacity(48);
248                line.push_str(failure.prefixed_text());
249                line.push('\n');
250                Self::write_stream(true, line.as_bytes(), status_tx).await;
251                false
252            }
253        }
254    }
255
256    /// Write `bytes` to stdout (`stderr = false`) or stderr (`stderr = true`), flushing.
257    async fn write_stream(stderr: bool, bytes: &[u8], status_tx: &mpsc::UnboundedSender<String>) {
258        let res = if stderr {
259            let mut out = tokio::io::stderr();
260            out.write_all(bytes).await.and(out.flush().await)
261        } else {
262            let mut out = tokio::io::stdout();
263            out.write_all(bytes).await.and(out.flush().await)
264        };
265        match res {
266            Ok(()) => {
267                let dest = if stderr { "stderr" } else { "stdout" };
268                debug!("stdio wrote {} bytes to {}", bytes.len(), dest);
269            }
270            Err(e) => {
271                Log::new(Some(status_tx)).error(format!("stdio write error: {}", e));
272            }
273        }
274    }
275}