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}