Skip to main content

agentmux_bashwrap/
mps_client.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Thin HTTP client for publishing MPS events to the AgentMux sidecar.
5//!
6//! Reads `AGENTMUX_LOCAL_URL` and `AGENTMUX_AUTH_KEY` from the
7//! process environment (inherited from the agentmux-srv → claude →
8//! bash subprocess chain). Attaches `X-AuthKey` per PR #801 to
9//! authenticate against the auth_middleware-gated publish endpoint.
10//!
11//! Designed to silently degrade: if env vars are absent or the
12//! sidecar is unreachable, publishes fail-fast and the caller emits
13//! a `kind: "system"` chunk that surfaces the degradation to the
14//! user without aborting the command itself.
15
16use agentmux_common::AUTH_KEY_HEADER;
17use agentmux_common::api_types::WpsPublishRequest;
18use agentmux_common::client_events::{CompactionStarted, ToolChunk};
19use anyhow::Result;
20use serde::Serialize;
21use std::sync::Arc;
22
23const PUBLISH_PATH: &str = "/agentmux/wps/publish";
24
25/// Per-launch sidecar config + reusable HTTP client. Cheap to clone
26/// (the inner `reqwest::Client` uses Arc internally).
27#[derive(Clone)]
28pub struct WpsClient {
29    inner: Arc<reqwest::Client>,
30    endpoint: String,
31    auth_key: String,
32}
33
34/// How many `tool_chunk` events the broker keeps per `block:<id>`
35/// scope. Sized for npm-install class output (~1000 lines is typical;
36/// 1024 covers all but heavy CI builds). Each event is ~200–500 bytes,
37/// so the steady-state ceiling is ~512 KB per actively-streaming block.
38const TOOL_CHUNK_PERSIST: usize = 1024;
39
40/// Event name for streaming bash chunks. Held constant across every
41/// publish so the frontend can subscribe once per block on mount.
42const TOOL_CHUNK_EVENT: &str = "tool_chunk";
43
44/// Event name for the `PreCompact`-hook-sourced "compaction started"
45/// signal. See `docs/specs/SPEC_COMPACTION_DETECTION_AND_HANDLING_2026_07_31.md`
46/// §4.2 / Tier 1.
47const COMPACTION_STARTED_EVENT: &str = "compaction_started";
48
49/// `persist: 0` — never retained/replayed. A late/reconnecting
50/// subscriber must NOT see a past "compaction started" ping: there
51/// is no matching completion tombstone (`compact_boundary` arrives
52/// over the separate NDJSON stream, not MPS), so replaying a
53/// retained start indistinguishably resurrects "Compacting…" for a
54/// compaction that may have finished seconds after the ping — a
55/// timestamp-age guard alone can't tell "recently finished" apart
56/// from "still running" (Codex P1, PR #2378 round 2). Only a
57/// currently-live subscriber should ever see this event; a
58/// disconnected pane already clears its own `compacting` flag on
59/// `StreamUnsubscribe` (reducer.ts), so there is nothing worth
60/// replaying on reconnect regardless.
61const COMPACTION_STARTED_PERSIST: usize = 0;
62
63/// Fail-fast timeout for `publish_compaction_started` specifically —
64/// much shorter than the 5s client-wide default used for `tool_chunk`
65/// streaming (`from_env`). That default is fine for `exec`'s own async
66/// runtime, but `precompact` is invoked as Claude Code's SYNCHRONOUS
67/// `PreCompact` hook: the CLI blocks compaction on this process's exit,
68/// so inheriting the 5s default means a wedged (but TCP-accepting)
69/// backend delays the user's actual compaction by up to 5 real seconds —
70/// directly contradicting this hook's own "never block/delay the
71/// operation for observability" contract (see `precompact.rs`'s module
72/// doc comment). Codex P2, PR #2378 round 4. 500ms comfortably covers a
73/// normal same-host round trip while keeping the worst case imperceptible.
74const COMPACTION_STARTED_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
75
76impl WpsClient {
77    /// Build a client from the env. Returns `None` when the required
78    /// env vars are missing — the caller surfaces a system chunk
79    /// rather than crashing.
80    pub fn from_env() -> Option<Self> {
81        let endpoint = std::env::var("AGENTMUX_LOCAL_URL").ok()?;
82        let auth_key = std::env::var("AGENTMUX_AUTH_KEY").ok()?;
83        if endpoint.is_empty() || auth_key.is_empty() {
84            return None;
85        }
86        let inner = reqwest::Client::builder()
87            // Snappy publish — chunks should land within a frame of
88            // emission. Long timeouts here cause back-pressure that
89            // stalls the PTY reader.
90            .timeout(std::time::Duration::from_secs(5))
91            .build()
92            .ok()?;
93        Some(Self {
94            inner: Arc::new(inner),
95            endpoint: endpoint.trim_end_matches('/').to_string(),
96            auth_key,
97        })
98    }
99
100    /// Publish a `tool_chunk` event. The tool_use_id lives in the
101    /// payload (not on this method's signature) so a single per-block
102    /// subscription receives chunks for every tool in that block.
103    /// Persists with `TOOL_CHUNK_PERSIST` so late subscribers (the
104    /// frontend, which learns about the tool_use only after Claude
105    /// buffers the stream) get the full history on subscribe.
106    pub async fn publish_chunk(
107        &self,
108        block_id: Option<&str>,
109        payload: &ToolChunk,
110    ) -> Result<()> {
111        self.publish(TOOL_CHUNK_EVENT, TOOL_CHUNK_PERSIST, block_id, payload, None)
112            .await
113    }
114
115    /// Publish a `compaction_started` event — the `PreCompact`-hook
116    /// signal that fires the instant Claude Code begins compacting.
117    /// `persist = 0`: live subscribers only, never replayed — see
118    /// `COMPACTION_STARTED_PERSIST`'s doc comment. Uses
119    /// `COMPACTION_STARTED_TIMEOUT` (much shorter than the client-wide
120    /// default) since this call sits on a synchronous hook Claude Code
121    /// blocks compaction on — see that constant's doc comment.
122    pub async fn publish_compaction_started(
123        &self,
124        block_id: Option<&str>,
125        payload: &CompactionStarted,
126    ) -> Result<()> {
127        self.publish(
128            COMPACTION_STARTED_EVENT,
129            COMPACTION_STARTED_PERSIST,
130            block_id,
131            payload,
132            Some(COMPACTION_STARTED_TIMEOUT),
133        )
134        .await
135    }
136
137    /// POST `body` as JSON to `path` on the sidecar and return its JSON
138    /// reply. `agent_token` goes out as `X-Agent-Token`, so the sidecar
139    /// knows which agent is asking (an agent's own env carries it as
140    /// `AGENTMUX_AGENT_TOKEN`). `timeout` replaces the client-wide default:
141    /// callers on a synchronous hook must not wait long.
142    pub async fn post_json(
143        &self,
144        path: &str,
145        agent_token: Option<&str>,
146        body: &impl Serialize,
147        timeout: std::time::Duration,
148    ) -> Result<serde_json::Value> {
149        let url = format!("{}{}", self.endpoint, path);
150        let mut req = self
151            .inner
152            .post(&url)
153            .header(AUTH_KEY_HEADER, &self.auth_key)
154            .header("Content-Type", "application/json")
155            .timeout(timeout)
156            .json(body);
157        if let Some(token) = agent_token.filter(|t| !t.is_empty()) {
158            req = req.header("X-Agent-Token", token);
159        }
160        let resp = req.send().await?;
161        let status = resp.status();
162        if !status.is_success() {
163            anyhow::bail!("{path}: HTTP {status}");
164        }
165        Ok(resp.json().await?)
166    }
167
168    /// Shared publish path for every MPS event this crate emits.
169    /// `event`/`persist` vary per call site; the scoping (`block:<id>`)
170    /// and auth/error-handling are identical across all of them.
171    /// `timeout_override` replaces the client-wide default
172    /// (`reqwest::RequestBuilder::timeout` overrides per-request) when
173    /// `Some` — used by call sites sitting on a synchronous caller that
174    /// must not inherit `from_env`'s more generous streaming timeout.
175    async fn publish(
176        &self,
177        event: &str,
178        persist: usize,
179        block_id: Option<&str>,
180        payload: &impl Serialize,
181        timeout_override: Option<std::time::Duration>,
182    ) -> Result<()> {
183        let body = WpsPublishRequest {
184            event: event.to_string(),
185            scopes: block_id.map(|b| vec![format!("block:{b}")]).unwrap_or_default(),
186            persist,
187            data: serde_json::to_value(payload)?,
188        };
189        let url = format!("{}{}", self.endpoint, PUBLISH_PATH);
190        let mut req = self
191            .inner
192            .post(&url)
193            .header(AUTH_KEY_HEADER, &self.auth_key)
194            .header("Content-Type", "application/json")
195            .json(&body);
196        if let Some(d) = timeout_override {
197            req = req.timeout(d);
198        }
199        let resp = req
200            .send()
201            .await?;
202        if !resp.status().is_success() {
203            let status = resp.status();
204            let text = resp.text().await.unwrap_or_default();
205            anyhow::bail!("publish failed: HTTP {} — {}", status, text);
206        }
207        Ok(())
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::test_env_lock::ENV_LOCK;
215
216    // `std::env::set_var` / `remove_var` mutate process-global state;
217    // cargo test runs tests in parallel by default, so without a
218    // serial lock these races between threads (codex P1 on PR #804).
219    // Shared with `precompact.rs`'s test module, which mutates the SAME
220    // env vars — a private per-module lock doesn't actually synchronize
221    // cross-module (Codex P2, PR #2378 round 6).
222    fn clear_env() {
223        std::env::remove_var("AGENTMUX_LOCAL_URL");
224        std::env::remove_var("AGENTMUX_AUTH_KEY");
225    }
226
227    #[test]
228    fn from_env_returns_none_without_endpoint() {
229        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
230        clear_env();
231        assert!(WpsClient::from_env().is_none());
232    }
233
234    #[test]
235    fn from_env_returns_none_when_only_endpoint_set() {
236        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
237        clear_env();
238        std::env::set_var("AGENTMUX_LOCAL_URL", "http://127.0.0.1:1");
239        assert!(WpsClient::from_env().is_none());
240        clear_env();
241    }
242
243    #[test]
244    fn from_env_strips_trailing_slash() {
245        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
246        clear_env();
247        std::env::set_var("AGENTMUX_LOCAL_URL", "http://127.0.0.1:9999/");
248        std::env::set_var("AGENTMUX_AUTH_KEY", "x");
249        let c = WpsClient::from_env().expect("client");
250        assert_eq!(c.endpoint, "http://127.0.0.1:9999");
251        clear_env();
252    }
253
254    // ── event/persist constants (refactor of `publish` into a shared
255    // helper — SPEC_COMPACTION_DETECTION_AND_HANDLING_2026_07_31.md §4.2) ──
256
257    #[test]
258    fn compaction_started_event_name_and_persist() {
259        assert_eq!(COMPACTION_STARTED_EVENT, "compaction_started");
260        // Never retained/replayed (Codex P1, PR #2378 round 2) — a
261        // reconnecting subscriber must not resurrect a past ping for
262        // a compaction that has since finished; see the constant's
263        // doc comment for why an age-based guard alone can't fix
264        // this on the receiving end.
265        assert_eq!(COMPACTION_STARTED_PERSIST, 0);
266    }
267
268    #[test]
269    fn compaction_started_uses_a_short_fail_fast_timeout() {
270        // Codex P2, PR #2378 round 4: this publish sits on Claude Code's
271        // SYNCHRONOUS PreCompact hook -- the CLI blocks compaction on this
272        // process's exit, so it must not inherit the 5s client-wide
273        // default used for tool_chunk streaming (from_env), or a wedged
274        // backend delays the user's actual compaction by up to 5 real
275        // seconds. Comfortably shorter than the client default, long
276        // enough for a normal same-host round trip.
277        assert!(COMPACTION_STARTED_TIMEOUT < std::time::Duration::from_secs(5));
278        assert_eq!(COMPACTION_STARTED_TIMEOUT, std::time::Duration::from_millis(500));
279    }
280
281    #[test]
282    fn tool_chunk_event_name_and_persist_unchanged_by_refactor() {
283        // The `publish` extraction must not change `publish_chunk`'s
284        // existing wire behavior.
285        assert_eq!(TOOL_CHUNK_EVENT, "tool_chunk");
286        assert_eq!(TOOL_CHUNK_PERSIST, 1024);
287    }
288}