Skip to main content

agentmux_srv/backend/
cli_notice.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pane notices about an agent's CLI
5//! (SPEC_LAUNCH_CONTEXT_WORKSPACE_RULE_AND_STARTUP_FILES_2026_09_30.md §6.3,
6//! phase LC4):
7//!
8//! - **Install:** while `ResolveCli` npm-installs the pinned CLI for a pane,
9//!   an `agentmux_cli_install` frame says so — `installing`, then
10//!   `installed` or `failed`, under one `install_id` so the row updates in
11//!   place. A pane restored at startup used to install silently.
12//! - **Version change:** each agent's last-run CLI version is kept in
13//!   [`RECORD_FILE`], in the channel directory ([`record_dir`]). When the agent's CLI next starts on a different
14//!   version, an `agentmux_cli_version_changed` frame tells the user
15//!   (`from`, `to`, and the version AgentMux pins).
16//!
17//! Both are `system` frames appended to the pane's output, so they replay
18//! with the transcript, and the digest never reads them (it only reads
19//! assistant/user/result lines). They are for the user; the agent's context
20//! is unchanged.
21
22use std::collections::BTreeMap;
23use std::path::Path;
24use std::sync::Arc;
25
26use serde::{Deserialize, Serialize};
27use serde_json::{json, Value};
28
29pub const INSTALL_SUBTYPE: &str = "agentmux_cli_install";
30pub const VERSION_CHANGED_SUBTYPE: &str = "agentmux_cli_version_changed";
31
32/// Per-agent last-run CLI versions, in [`record_dir`] (no schema change: a
33/// new column would lock older builds out of a shared store.db).
34pub const RECORD_FILE: &str = "agent-cli-versions.json";
35
36/// Where [`RECORD_FILE`] lives: the channel directory
37/// (`~/.agentmux/channels/<channel>/`, or a dev build's own root), next to
38/// the shared store that holds the agents it is keyed by. Not `data_dir`:
39/// an installed build's data dir is per AgentMux version
40/// (`channels/<ch>/versions/<v>/data/`), so after an upgrade every agent
41/// would read as a first run and the upgrade this exists to report would
42/// go unnoticed (ReAgent P1 on #4128).
43pub fn record_dir(paths: &agentmux_common::DataPaths) -> &Path {
44    &paths.instance_dir
45}
46
47/// Where an install stands.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum InstallState {
50    Installing,
51    Installed,
52    Failed,
53}
54
55impl InstallState {
56    fn as_str(self) -> &'static str {
57        match self {
58            Self::Installing => "installing",
59            Self::Installed => "installed",
60            Self::Failed => "failed",
61        }
62    }
63}
64
65/// The version number in a CLI's `--version` output, e.g. `"2.1.285"` from
66/// `"2.1.285 (Claude Code)"` or `"codex-cli 0.154.0"`. `None` if there is
67/// none (`"unknown"`, an empty string).
68pub fn version_token(raw: &str) -> Option<String> {
69    raw.split(|c: char| c.is_whitespace() || c == ',' || c == '(' || c == ')')
70        .map(|t| t.trim_start_matches('v'))
71        .find(|t| t.contains('.') && t.chars().next().is_some_and(|c| c.is_ascii_digit()))
72        .map(str::to_string)
73}
74
75/// An `agentmux_cli_install` frame. `install_id` is the same for every frame
76/// of one install, so the pane updates one row.
77pub fn install_frame(
78    install_id: &str,
79    provider: &str,
80    version: &str,
81    state: InstallState,
82    seconds: Option<f64>,
83    error: Option<&str>,
84) -> Value {
85    let mut frame = json!({
86        "type": "system",
87        "subtype": INSTALL_SUBTYPE,
88        "install_id": install_id,
89        "provider": provider,
90        "version": version,
91        "state": state.as_str(),
92        "timestamp": chrono::Utc::now().to_rfc3339(),
93    });
94    if let Some(s) = seconds {
95        frame["seconds"] = json!((s * 10.0).round() / 10.0);
96    }
97    if let Some(e) = error {
98        frame["error"] = json!(e);
99    }
100    frame
101}
102
103/// An `agentmux_cli_version_changed` frame. `pinned` is the version this
104/// AgentMux build installs, so the pane can say when `to` isn't it (a
105/// self-update, or a stale install).
106pub fn version_changed_frame(provider: &str, from: &str, to: &str, pinned: Option<&str>) -> Value {
107    json!({
108        "type": "system",
109        "subtype": VERSION_CHANGED_SUBTYPE,
110        "provider": provider,
111        "from": from,
112        "to": to,
113        "pinned": pinned,
114        "timestamp": chrono::Utc::now().to_rfc3339(),
115    })
116}
117
118/// Append `frame` to `block_id`'s output (and the agent's global transcript
119/// zone), as the memory-delivery notice does.
120pub fn append_to_pane(
121    broker: &Arc<crate::backend::mps::Broker>,
122    filestore: &Arc<crate::backend::storage::filestore::FileStore>,
123    mstore: &Arc<crate::backend::storage::store::Store>,
124    block_id: &str,
125    frame: &Value,
126) {
127    let line = format!("{frame}\n");
128    let zone = crate::backend::blockcontroller::shell::resolve_global_output_zone(&Some(mstore.clone()), block_id);
129    crate::backend::blockcontroller::shell::handle_append_block_file(
130        broker,
131        block_id,
132        crate::backend::blockcontroller::persistent::PERSISTENT_OUTPUT_SUBJECT,
133        line.as_bytes(),
134        Some(filestore),
135        zone.as_deref(),
136    );
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140struct LastCli {
141    provider: String,
142    version: String,
143    at: i64,
144}
145
146/// Serializes read-modify-write of [`RECORD_FILE`] within this srv.
147static RECORD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
148
149/// Record that `agent_key`'s CLI just started on `version`, and return the
150/// version it last ran if that was a different one of the same provider.
151/// A first run, or a switch to another provider, records and returns `None`.
152/// A record that can't be read or written is logged and treated as empty:
153/// at worst a notice is missed, never a launch.
154pub fn observe_version(dir: &Path, agent_key: &str, provider: &str, version: &str) -> Option<String> {
155    let _guard = RECORD_LOCK.lock().unwrap_or_else(|p| p.into_inner());
156    let path = dir.join(RECORD_FILE);
157    let mut records: BTreeMap<String, LastCli> = std::fs::read(&path)
158        .ok()
159        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
160        .unwrap_or_default();
161    let previous = records.get(agent_key).cloned();
162    if previous.as_ref().is_some_and(|p| p.provider == provider && p.version == version) {
163        return None;
164    }
165    records.insert(
166        agent_key.to_string(),
167        LastCli { provider: provider.to_string(), version: version.to_string(), at: agentmux_common::time::now_ms() },
168    );
169    let tmp = path.with_extension("json.tmp");
170    let written = serde_json::to_vec_pretty(&records)
171        .map_err(|e| e.to_string())
172        .and_then(|bytes| std::fs::write(&tmp, bytes).map_err(|e| e.to_string()))
173        .and_then(|()| std::fs::rename(&tmp, &path).map_err(|e| e.to_string()));
174    if let Err(e) = written {
175        tracing::warn!(path = %path.display(), error = %e, "cli notice: could not save the last-run CLI version");
176    }
177    previous.filter(|p| p.provider == provider).map(|p| p.version)
178}
179
180/// The key an agent's record is kept under: the block's `agentId` (the
181/// agent's UID, or a provider key for a quick-launch pane). `None` for a
182/// block with no agent.
183pub fn agent_key_of_block(mstore: &crate::backend::storage::store::Store, block_id: &str) -> Option<String> {
184    let block = mstore.get::<crate::backend::obj::Block>(block_id).ok().flatten()?;
185    let id = crate::backend::obj::meta_get_string(&block.meta, "agentId", "");
186    (!id.trim().is_empty()).then_some(id)
187}
188
189/// Observe `version` for an agent and, if it changed, append the notice to
190/// `block_id`'s pane. The agent is `agent_id` when the caller knows it (a
191/// new pane resolves its CLI before its `agentId` meta is written), else
192/// the pane's `agentId`. The one entry point both sources use: Claude's own
193/// `system/init` frame, and `ResolveCli` for the other providers.
194pub fn observe_and_notify(
195    broker: &Arc<crate::backend::mps::Broker>,
196    filestore: &Arc<crate::backend::storage::filestore::FileStore>,
197    mstore: &Arc<crate::backend::storage::store::Store>,
198    block_id: &str,
199    agent_id: Option<&str>,
200    provider: &str,
201    version: &str,
202) {
203    let Some(agent_key) = agent_id.map(str::to_string).or_else(|| agent_key_of_block(mstore, block_id)) else {
204        return;
205    };
206    let Some(paths) = agentmux_common::DataPaths::from_env() else { return };
207    if let Some(from) = observe_version(record_dir(&paths), &agent_key, provider, version) {
208        let pinned = crate::backend::providers::get_provider(provider).map(|p| p.pinned_version);
209        tracing::info!(block_id, provider, from = %from, to = version, "cli notice: agent's CLI version changed");
210        append_to_pane(broker, filestore, mstore, block_id, &version_changed_frame(provider, &from, version, pinned));
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn version_token_finds_the_number() {
220        assert_eq!(version_token("2.1.285 (Claude Code)").as_deref(), Some("2.1.285"));
221        assert_eq!(version_token("codex-cli 0.154.0").as_deref(), Some("0.154.0"));
222        assert_eq!(version_token("v0.60.0").as_deref(), Some("0.60.0"));
223        assert_eq!(version_token("unknown"), None);
224        assert_eq!(version_token(""), None);
225    }
226
227    #[test]
228    fn first_run_records_without_a_notice_then_a_change_returns_the_old_version() {
229        let dir = tempfile::tempdir().unwrap();
230        assert_eq!(observe_version(dir.path(), "uid-1", "claude", "2.1.218"), None);
231        assert_eq!(observe_version(dir.path(), "uid-1", "claude", "2.1.218"), None, "same version");
232        assert_eq!(observe_version(dir.path(), "uid-1", "claude", "2.1.285").as_deref(), Some("2.1.218"));
233        assert_eq!(observe_version(dir.path(), "uid-1", "claude", "2.1.285"), None, "recorded the new one");
234    }
235
236    #[test]
237    fn agents_are_independent_and_a_provider_switch_is_not_an_upgrade() {
238        let dir = tempfile::tempdir().unwrap();
239        observe_version(dir.path(), "uid-1", "claude", "2.1.218");
240        assert_eq!(observe_version(dir.path(), "uid-2", "claude", "2.1.285"), None, "other agent's first run");
241        assert_eq!(observe_version(dir.path(), "uid-1", "codex", "0.154.0"), None, "switched provider");
242        assert_eq!(observe_version(dir.path(), "uid-1", "codex", "0.155.0").as_deref(), Some("0.154.0"));
243    }
244
245    #[test]
246    fn an_unreadable_record_is_treated_as_empty() {
247        let dir = tempfile::tempdir().unwrap();
248        std::fs::write(dir.path().join(RECORD_FILE), b"not json").unwrap();
249        assert_eq!(observe_version(dir.path(), "uid-1", "claude", "2.1.285"), None);
250        assert_eq!(observe_version(dir.path(), "uid-1", "claude", "2.1.286").as_deref(), Some("2.1.285"));
251    }
252
253    #[test]
254    fn frames_carry_what_the_pane_shows() {
255        let f = install_frame("claude-2.1.285-1", "claude", "2.1.285", InstallState::Installed, Some(14.04), None);
256        assert_eq!(f["subtype"], INSTALL_SUBTYPE);
257        assert_eq!(f["state"], "installed");
258        assert_eq!(f["seconds"], 14.0);
259        assert!(f.get("error").is_none());
260        let failed = install_frame("i", "claude", "2.1.285", InstallState::Failed, None, Some("exit 1"));
261        assert_eq!(failed["error"], "exit 1");
262
263        let v = version_changed_frame("claude", "2.1.218", "2.1.285", Some("2.1.285"));
264        assert_eq!(v["subtype"], VERSION_CHANGED_SUBTYPE);
265        assert_eq!((v["from"].as_str(), v["to"].as_str(), v["pinned"].as_str()), (Some("2.1.218"), Some("2.1.285"), Some("2.1.285")));
266    }
267}