Skip to main content

agentmux_srv/backend/
agent_line.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! An agent's summary line as other instances see it: the line under its Swarm
5//! row, sent with its status on `GET /agentmux/reactive/agent-names` so this
6//! machine's other channels and LAN hosts can show it on their remote rows
7//! (SPEC_SWARM_OTHER_HOSTS_AND_CHANNELS_2026_10_02.md §3.4 Phase 4).
8//!
9//! The line is the frontend's `readSwarmSummary`: the generated title when it
10//! is usable, else the CLI's OSC title. The local row's fallbacks (a restored
11//! title, a one-liner from the last prompt, a status phrase) are not sent.
12//!
13//! Sent unless `swarm:sharesummaries` is off, by the owner's decision of
14//! 2026-10-13 (on by default, every tier). [`clean_line`] bounds a line on both
15//! sides: a peer is untrusted.
16
17use std::collections::BTreeMap;
18
19use crate::backend::obj::{self, MetaMapType};
20use crate::backend::storage::store::Store;
21use crate::backend::wconfig::SettingsType;
22
23/// The CLI's own window title (`frontend/app/store/meta-keys.ts`).
24pub const META_OSC_TITLE: &str = "term:osc_title";
25
26/// Longest line sent or kept, in characters. A stored title is at most 200.
27pub const MAX_LINE_CHARS: usize = 200;
28
29/// "Share what my agents are working on". Absent means on. The key is written
30/// out at the read so the settings-defaults check finds this default.
31pub fn sharing_on(settings: &SettingsType) -> bool {
32    settings.extra.get("swarm:sharesummaries").and_then(|v| v.as_bool()).unwrap_or(true)
33}
34
35/// One line: whitespace and control characters folded to single spaces,
36/// trimmed and capped at [`MAX_LINE_CHARS`]. `None` when nothing is left.
37pub fn clean_line(text: &str) -> Option<String> {
38    let folded = text
39        .split(|c: char| c.is_whitespace() || c.is_control())
40        .filter(|w| !w.is_empty())
41        .collect::<Vec<_>>()
42        .join(" ");
43    let line: String = folded.chars().take(MAX_LINE_CHARS).collect();
44    (!line.is_empty()).then_some(line)
45}
46
47/// The line for a block's meta, or `None` when it has none to show.
48pub fn line_from_meta(meta: &MetaMapType) -> Option<String> {
49    let title = obj::meta_get_string(meta, crate::ambient::title::META_TITLE, "");
50    if crate::ambient::validate::is_usable_title(&title) {
51        if let Some(line) = clean_line(&title) {
52            return Some(line);
53        }
54    }
55    clean_line(&obj::meta_get_string(meta, META_OSC_TITLE, ""))
56}
57
58/// The line of the agent on `block_id`. Reads the store.
59pub fn agent_line_of_block(mstore: &Store, block_id: &str) -> Option<String> {
60    let block = mstore.get::<obj::Block>(block_id).ok()??;
61    line_from_meta(&block.meta)
62}
63
64/// Each agent's line by name, an agent with none left out, for the names
65/// route; `None` when `swarm:sharesummaries` is off. `agents` is
66/// `(name, block_id)`. Reads the store.
67pub fn lines_by_name<'a>(
68    mstore: &Store,
69    settings: &SettingsType,
70    agents: impl IntoIterator<Item = (&'a str, &'a str)>,
71) -> Option<BTreeMap<&'a str, String>> {
72    sharing_on(settings).then(|| {
73        agents
74            .into_iter()
75            .filter_map(|(name, block_id)| Some((name, agent_line_of_block(mstore, block_id)?)))
76            .collect()
77    })
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    fn meta(pairs: &[(&str, &str)]) -> MetaMapType {
85        pairs.iter().map(|(k, v)| (k.to_string(), serde_json::json!(v))).collect()
86    }
87
88    #[test]
89    fn the_generated_title_wins_over_the_osc_title() {
90        let m = meta(&[
91            (crate::ambient::title::META_TITLE, "Fixing the login race"),
92            (META_OSC_TITLE, "claude"),
93        ]);
94        assert_eq!(line_from_meta(&m).as_deref(), Some("Fixing the login race"));
95    }
96
97    #[test]
98    fn an_unusable_title_falls_back_to_the_osc_title_and_then_to_none() {
99        let m = meta(&[(crate::ambient::title::META_TITLE, "(none yet)"), (META_OSC_TITLE, " Build ")]);
100        assert_eq!(line_from_meta(&m).as_deref(), Some("Build"));
101        assert_eq!(line_from_meta(&meta(&[(crate::ambient::title::META_TITLE, "none")])), None);
102        assert_eq!(line_from_meta(&MetaMapType::default()), None);
103    }
104
105    #[test]
106    fn a_line_is_one_bounded_line() {
107        assert_eq!(clean_line("  two\nlines\t\u{7}here ").as_deref(), Some("two lines here"));
108        assert_eq!(clean_line(" \n\t "), None);
109        let long = "é".repeat(MAX_LINE_CHARS + 50);
110        assert_eq!(clean_line(&long).unwrap().chars().count(), MAX_LINE_CHARS);
111    }
112
113    #[test]
114    fn sharing_is_on_unless_turned_off() {
115        let read = |v: serde_json::Value| sharing_on(&serde_json::from_value(v).unwrap());
116        assert!(read(serde_json::json!({})));
117        assert!(!read(serde_json::json!({ "swarm:sharesummaries": false })));
118        assert!(read(serde_json::json!({ "swarm:sharesummaries": "no" })), "not a boolean: the default");
119    }
120}