netget/server/stdio/
mod.rs1#![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
41static STDIO_CLAIMED: AtomicBool = AtomicBool::new(false);
43
44struct StdioClaimGuard;
46
47impl Drop for StdioClaimGuard {
48 fn drop(&mut self) {
49 STDIO_CLAIMED.store(false, Ordering::SeqCst);
50 }
51}
52
53fn running_as_mcp_stdio() -> bool {
57 std::env::args().any(|a| a == "--mcp" || a == "--mcp-stdio")
58}
59
60pub struct StdioServer;
62
63impl StdioServer {
64 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 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; 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 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 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 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 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 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}