#![cfg(unix)]
pub mod actions;
use anyhow::Result;
use std::io::IsTerminal;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::mpsc;
use tracing::{debug, info};
use crate::llm::action_helper::call_llm;
use crate::llm::ollama_client::OllamaClient;
use crate::llm::ActionResult;
use crate::logging::emit::Log;
use crate::protocol::Event;
use crate::server::stdio::actions::{
StdioProtocol, STDIO_INPUT_CLOSED_EVENT, STDIO_INPUT_RECEIVED_EVENT, STDIO_STARTED_EVENT,
};
use crate::state::app_state::AppState;
static STDIO_CLAIMED: AtomicBool = AtomicBool::new(false);
struct StdioClaimGuard;
impl Drop for StdioClaimGuard {
fn drop(&mut self) {
STDIO_CLAIMED.store(false, Ordering::SeqCst);
}
}
fn running_as_mcp_stdio() -> bool {
std::env::args().any(|a| a == "--mcp" || a == "--mcp-stdio")
}
pub struct StdioServer;
impl StdioServer {
pub async fn spawn_with_llm_actions(
send_first: bool,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
) -> Result<()> {
if std::io::stdin().is_terminal() {
anyhow::bail!(
"stdio server refuses to run under an interactive terminal: the TUI owns the \
terminal. Use it as a pipe filter (prog | netget --non-interactive '...' | prog) \
or under --mcp-http."
);
}
if running_as_mcp_stdio() {
anyhow::bail!(
"stdio server cannot run under --mcp (stdio): MCP JSON-RPC already owns this \
process's stdin/stdout. Use --mcp-http, or run NetGet headless as a pipe filter."
);
}
if STDIO_CLAIMED
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
anyhow::bail!(
"another stdio server already owns this process's stdin/stdout; only one is \
possible per process."
);
}
let claim = StdioClaimGuard;
Log::new(Some(&status_tx)).info("stdio server claimed process stdin/stdout");
let protocol = Arc::new(StdioProtocol::new());
let task_registrar = app_state.clone();
let handle = tokio::spawn(async move {
let _claim = claim;
if send_first {
let event = Event::new(&STDIO_STARTED_EVENT, serde_json::json!({}));
let _ = Self::dispatch(
&event,
&llm_client,
&app_state,
server_id,
protocol.as_ref(),
&status_tx,
)
.await;
}
let mut stdin = tokio::io::stdin();
let mut buffer = vec![0u8; 8192];
loop {
match stdin.read(&mut buffer).await {
Ok(0) => {
Log::new(Some(&status_tx)).debug("stdio stdin reached EOF");
let event = Event::new(&STDIO_INPUT_CLOSED_EVENT, serde_json::json!({}));
let _ = Self::dispatch(
&event,
&llm_client,
&app_state,
server_id,
protocol.as_ref(),
&status_tx,
)
.await;
break;
}
Ok(n) => {
let data = &buffer[..n];
let (data_str, encoding) = if data
.iter()
.all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
{
(String::from_utf8_lossy(data).to_string(), "utf8")
} else {
(hex::encode(data), "hex")
};
Log::new(Some(&status_tx))
.debug(format!("stdio read {} bytes from stdin ({})", n, encoding));
let event = Event::new(
&STDIO_INPUT_RECEIVED_EVENT,
serde_json::json!({ "data": data_str, "encoding": encoding }),
);
let should_close = Self::dispatch(
&event,
&llm_client,
&app_state,
server_id,
protocol.as_ref(),
&status_tx,
)
.await;
if should_close {
info!("stdio session closed by model (close_stdio)");
break;
}
}
Err(e) => {
Log::new(Some(&status_tx)).error(format!("stdio stdin read error: {}", e));
break;
}
}
}
});
task_registrar.register_server_task(server_id, handle).await;
Ok(())
}
async fn dispatch(
event: &Event,
llm_client: &OllamaClient,
app_state: &Arc<AppState>,
server_id: crate::state::ServerId,
protocol: &StdioProtocol,
status_tx: &mpsc::UnboundedSender<String>,
) -> bool {
match call_llm(llm_client, app_state, server_id, None, event, protocol).await {
Ok(result) => {
for msg in result.messages {
let _ = status_tx.send(msg);
}
let mut should_close = false;
let mut wrote = false;
for pr in result.protocol_results {
match pr {
ActionResult::Output(bytes) => {
wrote = true;
Self::write_stream(false, &bytes, status_tx).await;
}
ActionResult::Custom { name, data } if name == "stdio_stderr" => {
wrote = true;
if let Some(hex_str) = data.get("hex").and_then(|v| v.as_str()) {
if let Ok(bytes) = hex::decode(hex_str) {
Self::write_stream(true, &bytes, status_tx).await;
}
}
}
ActionResult::CloseConnection => should_close = true,
_ => {}
}
}
let decision = match (wrote, should_close) {
(_, true) => "model_close",
(true, false) => "model_answer",
(false, false) => "model_silent",
};
debug!("stdio {} decision={}", event.event_type.id, decision);
should_close
}
Err(e) => {
let failure = crate::utils::WireFailure::classify(&e);
let decision = if failure.is_overloaded() {
"fail_closed_overloaded"
} else {
"fail_closed_unavailable"
};
Log::new(Some(status_tx)).error(format!(
"stdio {} decision={} error: {}",
event.event_type.id, decision, e
));
let mut line = String::with_capacity(48);
line.push_str(failure.prefixed_text());
line.push('\n');
Self::write_stream(true, line.as_bytes(), status_tx).await;
false
}
}
}
async fn write_stream(stderr: bool, bytes: &[u8], status_tx: &mpsc::UnboundedSender<String>) {
let res = if stderr {
let mut out = tokio::io::stderr();
out.write_all(bytes).await.and(out.flush().await)
} else {
let mut out = tokio::io::stdout();
out.write_all(bytes).await.and(out.flush().await)
};
match res {
Ok(()) => {
let dest = if stderr { "stderr" } else { "stdout" };
debug!("stdio wrote {} bytes to {}", bytes.len(), dest);
}
Err(e) => {
Log::new(Some(status_tx)).error(format!("stdio write error: {}", e));
}
}
}
}