Skip to main content

netget/client/gopher/
mod.rs

1//! Gopher (RFC 1436) client.
2//!
3//! # One connection per request, deliberately
4//!
5//! Gopher has no session. The client sends one selector line, the server answers and
6//! **closes** — the close is the framing, because the reply carries no length and no content
7//! type. So there is no persistent socket for this client to hold, and "browsing" means
8//! opening a new connection per item.
9//!
10//! That is exactly what happens here: [`GopherClient::connect_with_llm_actions`] opens one
11//! connection to prove the hole is reachable and to obtain the local address the `Client`
12//! trait must return, then closes it. Every fetch afterwards — the model's, or one injected
13//! from the dashboard — opens its own connection to whatever host and port the action names,
14//! which is what lets a menu item point at a different server and still be followable.
15//!
16//! # Menu or document: the requesting item's type decides
17//!
18//! Nothing in a Gopher reply says which it is. The item type in the *request* is the only
19//! signal, so `send_gopher_request` carries an `item_type` and this module reads the reply
20//! according to it: `1` and `7` are parsed as menus, everything else as a document. There is
21//! exactly one exception, and it is not a heuristic about content: a reply whose first line
22//! is a **type-3 item** is reported as an error whatever was asked for, because type 3 is the
23//! only error channel the protocol has and a server uses it to refuse any request.
24//!
25//! When the guess is wrong the reply is not thrown away. A menu request whose reply contains
26//! no parseable menu line at all is reported as `gopher_document_received` with
27//! `requested_item_type` still set, so the model can see the mismatch; a menu request whose
28//! reply is *mostly* menu lines keeps the odd ones in `malformed_lines`. The reverse is not
29//! attempted — a document may legitimately be full of tabs, so a document reply is never
30//! re-read as a menu.
31//!
32//! # The follow-up chain
33//!
34//! Browsing is iterative by nature: fetch a menu, pick an item, fetch that, repeat. A
35//! follow-up fetch that raised no event would give the model exactly one turn and then go
36//! deaf, which is the `elasticsearch`/`http2` defect the root `CLAUDE.md` catalogues. So every
37//! fetch raises its event and asks the model again, and the recursion is bounded by
38//! [`MAX_FOLLOWUP_DEPTH`] rather than by silence. The recursive call is boxed (an `async fn`
39//! that awaits itself has an infinitely-sized future) and the boxed type names `+ Send`
40//! explicitly, because it is awaited inside a `tokio::spawn`.
41
42pub mod actions;
43
44pub use actions::GopherClientProtocol;
45
46use std::net::SocketAddr;
47use std::sync::atomic::{AtomicBool, Ordering};
48use std::sync::Arc;
49use std::time::Duration;
50
51use anyhow::{anyhow, Context, Result};
52use serde_json::json;
53use tokio::io::{AsyncReadExt, AsyncWriteExt};
54use tokio::net::TcpStream;
55use tokio::sync::mpsc;
56use tracing::{debug, error, info, trace, warn};
57
58use crate::client::llm_budget::call_llm_for_client;
59use crate::llm::actions::client_trait::{Client, ClientActionResult};
60use crate::llm::actions::protocol_trait::Protocol;
61use crate::llm::ollama_client::OllamaClient;
62use crate::llm::ClientLlmResult;
63use crate::protocol::Event;
64use crate::state::app_state::AppState;
65use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
66use crate::state::{AccessLogOwner, ClientId, ClientStatus};
67
68use actions::{
69    GOPHER_DOCUMENT_RECEIVED_EVENT, GOPHER_ERROR_RECEIVED_EVENT, GOPHER_FETCH_RESULT,
70    GOPHER_MENU_RECEIVED_EVENT,
71};
72
73/// How many model round-trips one automatic browse may chain.
74///
75/// A menu whose items point back at each other is an ordinary shape in gopherspace, so the
76/// chain has to be bounded by something. Silence is the wrong bound — that is the defect this
77/// module exists not to have — so the bound is a depth, and hitting it is logged loudly.
78pub const MAX_FOLLOWUP_DEPTH: usize = 6;
79
80/// Largest reply this client will hold. Gopher sends no length, so without a cap any server
81/// can make the client allocate until it dies.
82pub const MAX_REPLY_BYTES: usize = 1024 * 1024;
83
84/// Menu items kept from one reply. A menu longer than this is a server misbehaving.
85pub const MAX_MENU_ITEMS: usize = 4096;
86
87const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
88const READ_TIMEOUT: Duration = Duration::from_secs(60);
89
90/// The default port when a remote address names no port. RFC 1436 §2.
91pub const DEFAULT_GOPHER_PORT: u16 = 70;
92
93// ---------------------------------------------------------------------------------------
94// Parsing
95// ---------------------------------------------------------------------------------------
96
97/// One parsed menu line.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct GopherMenuItem {
100    /// RFC 1436 item type: the first character of the line.
101    pub item_type: char,
102    /// The text a user sees.
103    pub display: String,
104    /// What to send back to fetch this item.
105    pub selector: String,
106    /// Where to fetch it.
107    pub host: String,
108    /// Port to fetch it on.
109    pub port: u16,
110}
111
112/// What a reply turned out to be.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum GopherReply {
115    /// A directory listing.
116    Menu {
117        items: Vec<GopherMenuItem>,
118        /// Lines that are not well-formed menu lines, kept verbatim rather than dropped.
119        malformed_lines: Vec<String>,
120    },
121    /// A text document, with the terminator removed and leading dots un-doubled.
122    Document { text: String },
123    /// A type-3 item.
124    Error { message: String },
125}
126
127/// What an RFC 1436 item type character means, for the model's benefit.
128///
129/// The client recognises every type the RFC and common practice define, unlike the server,
130/// which deliberately emits only a closed set. A client has no choice: it must describe
131/// whatever it is handed.
132pub fn item_type_name(item_type: char) -> &'static str {
133    match item_type {
134        '0' => "text file",
135        '1' => "directory (menu)",
136        '2' => "CSO phone-book server",
137        '3' => "error",
138        '4' => "BinHex file",
139        '5' => "DOS binary archive",
140        '6' => "uuencoded file",
141        '7' => "search server",
142        '8' => "telnet session",
143        '9' => "binary file",
144        '+' => "redundant server",
145        'T' => "tn3270 session",
146        'g' => "GIF image",
147        'I' => "image",
148        'h' => "HTML",
149        'i' => "informational text (not a link)",
150        'd' => "document",
151        's' => "sound",
152        _ => "unrecognised item type",
153    }
154}
155
156/// Split a reply into body lines: CRLF or LF, stopping at the lone `.` terminator.
157///
158/// Returns the lines and whether the terminator was actually seen. A server that just closes
159/// without one is tolerated — the FIN is the real end of the transfer — but the difference
160/// matters for the trailing blank line a final newline would otherwise leave behind.
161fn body_lines(raw: &str) -> (Vec<&str>, bool) {
162    let mut out: Vec<&str> = Vec::new();
163    let mut terminated = false;
164    for line in raw.split('\n') {
165        let line = line.strip_suffix('\r').unwrap_or(line);
166        if line == "." {
167            terminated = true;
168            break;
169        }
170        out.push(line);
171    }
172    if !terminated {
173        // The final `\n` of the last real line yields one empty trailing element; that is
174        // framing, not content. Anything before it is the author's own blank line.
175        if out.last() == Some(&"") {
176            out.pop();
177        }
178    }
179    (out, terminated)
180}
181
182/// Undo the leading-dot doubling ("periodating") RFC 1436 requires of the *sender*.
183///
184/// The server doubles a leading period so a line of its own that begins with one cannot be
185/// mistaken for the terminator; undoing it is the client's job and nothing else in the line
186/// is escaped.
187fn unescape_document_line(line: &str) -> &str {
188    line.strip_prefix('.').unwrap_or(line)
189}
190
191/// Parse one menu line: `<type><display>\t<selector>\t<host>\t<port>`.
192fn parse_menu_line(line: &str) -> Option<GopherMenuItem> {
193    let mut chars = line.chars();
194    let item_type = chars.next()?;
195    let rest = chars.as_str();
196
197    let fields: Vec<&str> = rest.split('\t').collect();
198    if fields.len() < 4 {
199        return None;
200    }
201    // A display string may not contain a tab (the server sanitises it), so field 0 is the
202    // display and the last three named fields are selector/host/port. Anything beyond four
203    // fields is a Gopher+ attribute suffix, which this client ignores.
204    let port = fields[3].trim().parse::<u16>().ok()?;
205
206    Some(GopherMenuItem {
207        item_type,
208        display: fields[0].to_string(),
209        selector: fields[1].to_string(),
210        host: fields[2].to_string(),
211        port,
212    })
213}
214
215/// Read a reply according to the item type that was asked for.
216///
217/// `requested_item_type` is the whole point: the reply itself carries no content type, so the
218/// request is the only thing that says what it is. See the module docs for what happens when
219/// that is wrong.
220pub fn parse_gopher_reply(raw: &str, requested_item_type: char) -> GopherReply {
221    let (lines, _terminated) = body_lines(raw);
222
223    // A type-3 item can answer any request, so it is checked before anything else. The test
224    // is structural (a type-3 *item*, with the tab-separated fields a menu line has), not a
225    // guess about content: a document that merely begins with the digit 3 has no tab there.
226    if let Some(first) = lines.iter().find(|l| !l.is_empty()) {
227        if first.starts_with('3') && first.contains('\t') {
228            let message = first[1..].split('\t').next().unwrap_or("").to_string();
229            return GopherReply::Error { message };
230        }
231    }
232
233    let wants_menu = requested_item_type == '1' || requested_item_type == '7';
234    if wants_menu {
235        let mut items = Vec::new();
236        let mut malformed_lines = Vec::new();
237        for line in &lines {
238            if line.is_empty() {
239                continue;
240            }
241            match parse_menu_line(line) {
242                Some(item) if items.len() < MAX_MENU_ITEMS => items.push(item),
243                Some(_) => break,
244                None => malformed_lines.push((*line).to_string()),
245            }
246        }
247        if !items.is_empty() {
248            return GopherReply::Menu {
249                items,
250                malformed_lines,
251            };
252        }
253        // The guess was wrong and nothing at all parsed. Report what arrived rather than an
254        // empty menu, and leave `requested_item_type` on the event so the mismatch is visible.
255    }
256
257    let text = lines
258        .iter()
259        .map(|l| unescape_document_line(l))
260        .collect::<Vec<_>>()
261        .join("\n");
262    GopherReply::Document { text }
263}
264
265/// Build the request line. A query makes it a type-7 search.
266pub fn request_line(selector: &str, query: Option<&str>) -> String {
267    match query {
268        Some(q) => format!("{selector}\t{q}\r\n"),
269        None => format!("{selector}\r\n"),
270    }
271}
272
273/// Split `host:port`, defaulting the port to 70.
274///
275/// Tolerates a bare IPv6 literal in brackets, which is the only form that can carry a port
276/// unambiguously.
277fn split_remote_addr(remote_addr: &str) -> (String, u16) {
278    if let Some(rest) = remote_addr.strip_prefix('[') {
279        if let Some((host, tail)) = rest.split_once(']') {
280            let port = tail
281                .strip_prefix(':')
282                .and_then(|p| p.parse::<u16>().ok())
283                .unwrap_or(DEFAULT_GOPHER_PORT);
284            return (host.to_string(), port);
285        }
286    }
287    match remote_addr.rsplit_once(':') {
288        Some((host, port)) => match port.parse::<u16>() {
289            Ok(port) => (host.to_string(), port),
290            // A bare IPv6 literal has colons but no port.
291            Err(_) => (remote_addr.to_string(), DEFAULT_GOPHER_PORT),
292        },
293        None => (remote_addr.to_string(), DEFAULT_GOPHER_PORT),
294    }
295}
296
297/// Format a host and port for `TcpStream::connect`. A bare IPv6 literal needs brackets.
298fn dial_addr(host: &str, port: u16) -> String {
299    if host.contains(':') && !host.starts_with('[') {
300        format!("[{host}]:{port}")
301    } else {
302        format!("{host}:{port}")
303    }
304}
305
306// ---------------------------------------------------------------------------------------
307// The client
308// ---------------------------------------------------------------------------------------
309
310/// Everything one browsing session needs, so the recursive chain can be handed a single
311/// `Arc` instead of a dozen arguments.
312struct Session {
313    protocol: Arc<GopherClientProtocol>,
314    llm_client: OllamaClient,
315    app_state: Arc<AppState>,
316    status_tx: mpsc::UnboundedSender<String>,
317    client_id: ClientId,
318    /// Where a fetch goes when the action names no host/port.
319    default_host: String,
320    default_port: u16,
321    instruction: String,
322    /// Set once the session is over, so a fetch already queued does not put more bytes on
323    /// the wire after the operator hung up.
324    ended: Arc<AtomicBool>,
325}
326
327/// One fetch, resolved from an action.
328struct FetchRequest {
329    host: String,
330    port: u16,
331    selector: String,
332    item_type: char,
333    query: Option<String>,
334}
335
336/// What one fetch produced, before the model is told about it.
337struct FetchOutcome {
338    reply: GopherReply,
339    request_bytes: usize,
340    reply_bytes: usize,
341    truncated: bool,
342}
343
344/// Gopher client that browses a gopher hole under LLM control.
345pub struct GopherClient;
346
347impl GopherClient {
348    /// Open a Gopher client.
349    ///
350    /// The connection made here is a reachability probe and nothing more: Gopher expects a
351    /// selector immediately and we do not know one yet, and every request needs its own
352    /// connection anyway. Failing here rather than at the first fetch is what makes a
353    /// mistyped address show up as a failed `open_client` instead of as a silent client that
354    /// never does anything.
355    pub async fn connect_with_llm_actions(
356        remote_addr: String,
357        llm_client: OllamaClient,
358        app_state: Arc<AppState>,
359        status_tx: mpsc::UnboundedSender<String>,
360        client_id: ClientId,
361    ) -> Result<SocketAddr> {
362        let (default_host, default_port) = split_remote_addr(&remote_addr);
363        let dial = dial_addr(&default_host, default_port);
364
365        let probe = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&dial))
366            .await
367            .map_err(|_| anyhow!("timed out connecting to Gopher server at {dial}"))?
368            .with_context(|| format!("Failed to connect to Gopher server at {dial}"))?;
369        let local_addr = probe.local_addr()?;
370        let remote_sock_addr = probe.peer_addr()?;
371        // Nothing was sent, so the server sees a client that hung up before asking for
372        // anything and logs exactly that.
373        drop(probe);
374
375        info!(
376            "Gopher client {} reached {} (local: {}); every request opens its own connection",
377            client_id, remote_sock_addr, local_addr
378        );
379
380        app_state
381            .update_client_status(client_id, ClientStatus::Connected)
382            .await;
383        let _ = status_tx.send(format!(
384            "[CLIENT] Gopher client {} ready ({})",
385            client_id, dial
386        ));
387        let _ = status_tx.send("__UPDATE_UI__".to_string());
388
389        let session = Arc::new(Session {
390            protocol: Arc::new(GopherClientProtocol::new()),
391            llm_client,
392            app_state: app_state.clone(),
393            status_tx: status_tx.clone(),
394            client_id,
395            default_host,
396            default_port,
397            instruction: app_state
398                .get_instruction_for_client(client_id)
399                .await
400                .unwrap_or_default(),
401            ended: Arc::new(AtomicBool::new(false)),
402        });
403
404        // Registered before anything that can call the model. A dashboard-created client gets
405        // a `*` -> manual rule, so the first event this client raises parks for as long as the
406        // operator takes to answer it; without the handle in place first, `[ send ]` would be
407        // greyed out for exactly that window.
408        let command_rx =
409            crate::client::command_support::register_command_channel(&app_state, client_id).await;
410        let command_session = session.clone();
411        let command_task = tokio::spawn(async move {
412            Session::command_loop(command_session, command_rx).await;
413        });
414        app_state
415            .register_client_task(client_id, command_task)
416            .await;
417
418        let browse_session = session.clone();
419        let browse_task = tokio::spawn(async move {
420            Session::open_browse(browse_session).await;
421        });
422        app_state.register_client_task(client_id, browse_task).await;
423
424        Ok(local_addr)
425    }
426}
427
428impl Session {
429    /// The opening turn: ask the model what to fetch first, then run whatever it says.
430    ///
431    /// There is no `gopher_connected` event, because Gopher gives the client nothing to
432    /// report on connect — no banner, no capabilities, no session. This is the
433    /// initial-instruction call (`event: None`), which is also why it is not routed through
434    /// `event_handlers`: there is no event id to match a pattern against. A deterministic
435    /// first fetch is expressed by injecting the action instead.
436    async fn open_browse(self: Arc<Self>) {
437        if self.instruction.trim().is_empty() {
438            info!(
439                "Gopher client {} has no instruction; waiting for an injected request",
440                self.client_id
441            );
442            return;
443        }
444
445        let memory = self
446            .app_state
447            .get_memory_for_client(self.client_id)
448            .await
449            .unwrap_or_default();
450
451        match call_llm_for_client(
452            &self.llm_client,
453            &self.app_state,
454            self.client_id.to_string(),
455            &self.instruction,
456            &memory,
457            None,
458            self.protocol.as_ref(),
459            &self.status_tx,
460        )
461        .await
462        {
463            Ok(ClientLlmResult {
464                actions,
465                memory_updates,
466            }) => {
467                if let Some(mem) = memory_updates {
468                    self.app_state
469                        .set_memory_for_client(self.client_id, mem)
470                        .await;
471                }
472                Self::run_actions(self.clone(), actions, 0).await;
473            }
474            Err(e) => {
475                // Stay usable: the operator can still drive this client from the dashboard.
476                error!(
477                    "LLM error opening Gopher browse for client {}: {}",
478                    self.client_id, e
479                );
480                let _ = self.status_tx.send(format!(
481                    "[WARN] Gopher client {} could not ask the model what to fetch: {} \
482                     (a request can still be injected)",
483                    self.client_id, e
484                ));
485            }
486        }
487    }
488
489    /// Carry out the model's answer.
490    ///
491    /// Boxed because the chain is genuinely self-referential — an action leads to a fetch,
492    /// the fetch raises an event, the event is answered with more actions — and `+ Send` is
493    /// named explicitly because the whole thing is awaited inside a `tokio::spawn`.
494    fn run_actions(
495        session: Arc<Self>,
496        actions: Vec<serde_json::Value>,
497        depth: usize,
498    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
499        Box::pin(async move {
500            for action in actions {
501                if session.ended.load(Ordering::SeqCst) {
502                    debug!(
503                        "Gopher client {} stopped: session ended before the next action",
504                        session.client_id
505                    );
506                    return;
507                }
508
509                let result = match session.protocol.execute_action(action.clone()) {
510                    Ok(result) => result,
511                    Err(e) => {
512                        error!("Gopher client {} rejected action: {}", session.client_id, e);
513                        let _ = session.status_tx.send(format!(
514                            "[WARN] Gopher client {} rejected an action: {}",
515                            session.client_id, e
516                        ));
517                        continue;
518                    }
519                };
520
521                match result {
522                    ClientActionResult::Custom { name, data } if name == GOPHER_FETCH_RESULT => {
523                        let request = match session.resolve_fetch(&data) {
524                            Ok(request) => request,
525                            Err(e) => {
526                                error!(
527                                    "Gopher client {} could not resolve a fetch: {}",
528                                    session.client_id, e
529                                );
530                                continue;
531                            }
532                        };
533                        session.clone().fetch_and_notify(request, depth).await;
534                    }
535                    ClientActionResult::Disconnect => {
536                        session.end("the model ended the browsing session").await;
537                        return;
538                    }
539                    ClientActionResult::WaitForMore => {
540                        info!(
541                            "Gopher client {} is idle: the model asked to stop here",
542                            session.client_id
543                        );
544                    }
545                    ClientActionResult::NoAction => {}
546                    ClientActionResult::SendData(_) | ClientActionResult::Multiple(_) => {
547                        // Neither is producible by this protocol's executor.
548                        debug!(
549                            "Gopher client {} ignored an action result it does not produce",
550                            session.client_id
551                        );
552                    }
553                    ClientActionResult::Custom { name, .. } => {
554                        debug!(
555                            "Gopher client {} ignored unexpected custom result '{}'",
556                            session.client_id, name
557                        );
558                    }
559                }
560            }
561        })
562    }
563
564    /// Turn a `gopher_fetch` custom result into a concrete target.
565    fn resolve_fetch(&self, data: &serde_json::Value) -> Result<FetchRequest> {
566        let selector = data
567            .get("selector")
568            .and_then(|v| v.as_str())
569            .unwrap_or("")
570            .to_string();
571        let item_type = data
572            .get("item_type")
573            .and_then(|v| v.as_str())
574            .and_then(|s| s.chars().next())
575            .unwrap_or(actions::DEFAULT_ITEM_TYPE);
576        let host = data
577            .get("host")
578            .and_then(|v| v.as_str())
579            .filter(|h| !h.is_empty())
580            .unwrap_or(&self.default_host)
581            .to_string();
582        let port = data
583            .get("port")
584            .and_then(|v| v.as_u64())
585            .and_then(|p| u16::try_from(p).ok())
586            .filter(|p| *p != 0)
587            .unwrap_or(self.default_port);
588        let query = data
589            .get("query")
590            .and_then(|v| v.as_str())
591            .map(|s| s.to_string());
592
593        Ok(FetchRequest {
594            host,
595            port,
596            selector,
597            item_type,
598            query,
599        })
600    }
601
602    /// Fetch, raise the matching event, and act on the answer.
603    ///
604    /// The event is what keeps browsing iterative. A fetch that raised nothing would leave
605    /// the model with one turn and no way to say "now open that item", which is the whole
606    /// point of a menu.
607    async fn fetch_and_notify(self: Arc<Self>, request: FetchRequest, depth: usize) {
608        let outcome = match self.perform_fetch(&request).await {
609            Ok(outcome) => outcome,
610            Err(e) => {
611                error!(
612                    "Gopher client {} fetch of {:?} from {}:{} failed: {}",
613                    self.client_id, request.selector, request.host, request.port, e
614                );
615                let _ = self.status_tx.send(format!(
616                    "[WARN] Gopher client {} could not fetch {:?}: {}",
617                    self.client_id, request.selector, e
618                ));
619                let _ = self.status_tx.send("__UPDATE_UI__".to_string());
620                return;
621            }
622        };
623
624        let _ = self.status_tx.send("__UPDATE_UI__".to_string());
625        self.notify(&request, outcome, depth).await;
626    }
627
628    /// One request on its own connection: write the selector line, read to EOF.
629    async fn perform_fetch(&self, request: &FetchRequest) -> Result<FetchOutcome> {
630        if self.ended.load(Ordering::SeqCst) {
631            return Err(anyhow!("the browsing session has ended"));
632        }
633
634        let dial = dial_addr(&request.host, request.port);
635        let line = request_line(&request.selector, request.query.as_deref());
636
637        debug!(
638            "Gopher client {} fetching {:?} (type '{}') from {}",
639            self.client_id, request.selector, request.item_type, dial
640        );
641
642        let stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&dial))
643            .await
644            .map_err(|_| anyhow!("timed out connecting to {dial}"))?
645            .with_context(|| format!("could not connect to {dial}"))?;
646
647        let (mut read_half, mut write_half) = tokio::io::split(stream);
648        write_half.write_all(line.as_bytes()).await?;
649        write_half.flush().await?;
650        trace!(
651            "Gopher client {} sent request line: {:?}",
652            self.client_id,
653            line
654        );
655
656        // Read until EOF. The server closing is the only signal the transfer is complete —
657        // the reply has no length — so a size cap and a deadline are the only bounds there
658        // are.
659        let mut body: Vec<u8> = Vec::new();
660        let mut chunk = vec![0u8; 8192];
661        let mut truncated = false;
662        loop {
663            let n = tokio::time::timeout(READ_TIMEOUT, read_half.read(&mut chunk))
664                .await
665                .map_err(|_| anyhow!("timed out waiting for {dial} to finish its reply"))??;
666            if n == 0 {
667                break;
668            }
669            body.extend_from_slice(&chunk[..n]);
670            if body.len() >= MAX_REPLY_BYTES {
671                body.truncate(MAX_REPLY_BYTES);
672                truncated = true;
673                warn!(
674                    "Gopher client {} truncated a reply from {} at {} bytes",
675                    self.client_id, dial, MAX_REPLY_BYTES
676                );
677                break;
678            }
679        }
680        let _ = write_half.shutdown().await;
681
682        let reply_bytes = body.len();
683        let raw = String::from_utf8_lossy(&body).into_owned();
684        trace!("Gopher client {} reply:\n{}", self.client_id, raw);
685
686        Ok(FetchOutcome {
687            reply: parse_gopher_reply(&raw, request.item_type),
688            request_bytes: line.len(),
689            reply_bytes,
690            truncated,
691        })
692    }
693
694    /// Raise the event this reply calls for and run whatever the model answers with.
695    async fn notify(self: &Arc<Self>, request: &FetchRequest, outcome: FetchOutcome, depth: usize) {
696        let FetchOutcome {
697            reply,
698            reply_bytes,
699            truncated,
700            ..
701        } = outcome;
702
703        let (event, summary) = match reply {
704            GopherReply::Menu {
705                items,
706                malformed_lines,
707            } => {
708                let mut data = json!({
709                    "selector": request.selector,
710                    "host": request.host,
711                    "port": request.port,
712                    "item_count": items.len(),
713                    "items": items
714                        .iter()
715                        .map(|item| json!({
716                            "item_type": item.item_type.to_string(),
717                            "item_type_name": item_type_name(item.item_type),
718                            "display": item.display,
719                            "selector": item.selector,
720                            "host": item.host,
721                            "port": item.port,
722                        }))
723                        .collect::<Vec<_>>(),
724                });
725                if !malformed_lines.is_empty() {
726                    data["malformed_lines"] = json!(malformed_lines);
727                }
728                if truncated {
729                    data["truncated"] = json!(true);
730                }
731                let summary = format!("menu with {} item(s)", items.len());
732                (Event::new(&GOPHER_MENU_RECEIVED_EVENT, data), summary)
733            }
734            GopherReply::Document { text } => {
735                let line_count = if text.is_empty() {
736                    0
737                } else {
738                    text.split('\n').count()
739                };
740                let mut data = json!({
741                    "selector": request.selector,
742                    "host": request.host,
743                    "port": request.port,
744                    "requested_item_type": request.item_type.to_string(),
745                    "text": text,
746                    "line_count": line_count,
747                });
748                if truncated {
749                    data["truncated"] = json!(true);
750                }
751                (
752                    Event::new(&GOPHER_DOCUMENT_RECEIVED_EVENT, data),
753                    format!("document of {line_count} line(s)"),
754                )
755            }
756            GopherReply::Error { message } => {
757                let data = json!({
758                    "selector": request.selector,
759                    "host": request.host,
760                    "port": request.port,
761                    "message": message,
762                });
763                (
764                    Event::new(&GOPHER_ERROR_RECEIVED_EVENT, data),
765                    format!("error item: {message}"),
766                )
767            }
768        };
769
770        info!(
771            "Gopher client {} received {} bytes for {:?}: {}",
772            self.client_id, reply_bytes, request.selector, summary
773        );
774        let _ = self.status_tx.send(format!(
775            "[CLIENT] Gopher client {} <- {} for {:?}",
776            self.client_id, summary, request.selector
777        ));
778
779        if depth + 1 > MAX_FOLLOWUP_DEPTH {
780            warn!(
781                "Gopher client {} stopped following links at depth {}: the chain cap is {}",
782                self.client_id, depth, MAX_FOLLOWUP_DEPTH
783            );
784            let _ = self.status_tx.send(format!(
785                "[WARN] Gopher client {} stopped browsing after {} follow-ups",
786                self.client_id, MAX_FOLLOWUP_DEPTH
787            ));
788            return;
789        }
790
791        if self.ended.load(Ordering::SeqCst) {
792            return;
793        }
794
795        let memory = self
796            .app_state
797            .get_memory_for_client(self.client_id)
798            .await
799            .unwrap_or_default();
800
801        match call_llm_for_client(
802            &self.llm_client,
803            &self.app_state,
804            self.client_id.to_string(),
805            &self.instruction,
806            &memory,
807            Some(&event),
808            self.protocol.as_ref(),
809            &self.status_tx,
810        )
811        .await
812        {
813            Ok(ClientLlmResult {
814                actions,
815                memory_updates,
816            }) => {
817                if let Some(mem) = memory_updates {
818                    self.app_state
819                        .set_memory_for_client(self.client_id, mem)
820                        .await;
821                }
822                // The answer is carried out. Dropping it here is the single most common
823                // client defect in this repo, and for a browser it is fatal: the model would
824                // be shown a menu and given no way to open anything on it.
825                Self::run_actions(self.clone(), actions, depth + 1).await;
826            }
827            Err(e) => {
828                error!(
829                    "LLM error for Gopher client {} on {}: {}",
830                    self.client_id,
831                    event.id(),
832                    e
833                );
834                let _ = self.status_tx.send(format!(
835                    "[WARN] Gopher client {} could not act on {}: {}",
836                    self.client_id,
837                    event.id(),
838                    e
839                ));
840            }
841        }
842    }
843
844    /// End the session once, from whichever path got there first.
845    async fn end(&self, reason: &str) {
846        if self.ended.swap(true, Ordering::SeqCst) {
847            return;
848        }
849        info!("Gopher client {} finished: {}", self.client_id, reason);
850        self.app_state
851            .update_client_status(self.client_id, ClientStatus::Disconnected)
852            .await;
853        // Stop the dashboard offering [ send ] on a client that is done.
854        self.app_state.remove_client_handle(self.client_id).await;
855        let _ = self.status_tx.send(format!(
856            "[CLIENT] Gopher client {} disconnected ({})",
857            self.client_id, reason
858        ));
859        let _ = self.status_tx.send("__UPDATE_UI__".to_string());
860    }
861
862    /// Drain injected commands until the channel closes or one of them ends the session.
863    ///
864    /// A separate task, not a `select!` arm, and for the same reason WHOIS uses one: this
865    /// client's first model call can be parked by a manual handler for as long as the
866    /// operator takes, and `[ send ]` has to work during exactly that window.
867    ///
868    /// `command_support::handle_stream_client_command` cannot run this vocabulary — there is
869    /// no persistent write half to hand it, and the fetch verbs yield
870    /// `ClientActionResult::Custom` — so the action goes through the same
871    /// `perform_fetch`/`notify` pair the model's actions use.
872    ///
873    /// **The reply is sent as soon as the bytes have been exchanged, before the model is told
874    /// about them.** Telling the model can park for minutes behind a manual handler, and
875    /// `send_to_client` has a caller-supplied timeout; the caller asked whether the request
876    /// went out, which is answerable immediately. The consequence is that a second injected
877    /// command waits in the bounded channel until the first one's follow-up chain finishes,
878    /// which is ordinary "client busy" backpressure.
879    async fn command_loop(self: Arc<Self>, mut command_rx: mpsc::Receiver<ClientCommand>) {
880        while let Some(command) = command_rx.recv().await {
881            let action = command.action.clone();
882
883            let executed = self.protocol.execute_action(action.clone());
884            let mut pending: Option<(FetchRequest, FetchOutcome)> = None;
885            let mut disconnect = false;
886
887            let outcome: Result<ClientSendOutcome> = match executed {
888                Err(e) => Ok(ClientSendOutcome::Rejected {
889                    error: e.to_string(),
890                }),
891                Ok(ClientActionResult::Custom { name, data }) if name == GOPHER_FETCH_RESULT => {
892                    match self.resolve_fetch(&data) {
893                        Err(e) => Ok(ClientSendOutcome::Rejected {
894                            error: e.to_string(),
895                        }),
896                        Ok(request) => match self.perform_fetch(&request).await {
897                            Ok(fetched) => {
898                                let bytes_sent = fetched.request_bytes;
899                                pending = Some((request, fetched));
900                                Ok(ClientSendOutcome::Sent { bytes_sent })
901                            }
902                            Err(e) => Err(e),
903                        },
904                    }
905                }
906                Ok(ClientActionResult::Disconnect) => {
907                    disconnect = true;
908                    Ok(ClientSendOutcome::Disconnected)
909                }
910                Ok(ClientActionResult::WaitForMore) => Ok(ClientSendOutcome::Executed {
911                    detail: "wait_for_more (Gopher replies are never partial)".to_string(),
912                }),
913                Ok(_) => Ok(ClientSendOutcome::Executed {
914                    detail: "executed".to_string(),
915                }),
916            };
917
918            let outcome_json = match &outcome {
919                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
920                Err(e) => json!({"error": e.to_string()}),
921            };
922            self.app_state
923                .record_access_log(
924                    AccessLogOwner::Client(self.client_id.as_u32()),
925                    self.protocol.protocol_name(),
926                    None,
927                    "injected_action",
928                    action,
929                    vec![outcome_json],
930                )
931                .await;
932
933            if let Err(e) = &outcome {
934                error!(
935                    "Gopher client {} injected action failed: {}",
936                    self.client_id, e
937                );
938                let _ = self.status_tx.send(format!(
939                    "[WARN] Client {} injected action failed: {}",
940                    self.client_id, e
941                ));
942            }
943            let _ = self.status_tx.send("__UPDATE_UI__".to_string());
944            crate::client::command_support::reply(command, outcome);
945
946            if disconnect {
947                self.end("an injected disconnect ended the browsing session")
948                    .await;
949                break;
950            }
951
952            // Now tell the model, and let it browse on from here. An injected fetch is a real
953            // step in the session, not a side channel: the operator opening a menu should get
954            // the same follow-up behaviour the model's own fetch gets.
955            if let Some((request, fetched)) = pending {
956                self.notify(&request, fetched, 0).await;
957            }
958        }
959    }
960}