Skip to main content

agentmux_bashwrap/
precompact.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `precompact` subcommand.
5//!
6//! Registered as Claude Code's `PreCompact` hook (see
7//! `docs/specs/SPEC_COMPACTION_DETECTION_AND_HANDLING_2026_07_31.md`
8//! §4.2 / Tier 1). Fires synchronously the instant compaction begins —
9//! the only real-time "it's happening now" signal available, since
10//! the `compact_boundary` stream-json frame (`agentmux-srv`'s
11//! `claude.rs` translator) only arrives *after* compaction finishes.
12//!
13//! Contract corrections vs `PreToolUse` (see `hook.rs`, whose code
14//! *pattern* this mirrors but whose payload *shape* it does not):
15//!
16//! - `PreCompact`'s stdin payload carries only the common hook fields
17//!   (`session_id`, `transcript_path`, `cwd`, `permission_mode`,
18//!   `hook_event_name`) — there is **no `trigger` field on stdin**.
19//!   Claude Code requires a `matcher` (`"manual"` | `"auto"`) in
20//!   `settings.json` for `PreCompact` and provides no confirmed
21//!   wildcard, so `agent_config.rs` registers two separate hook
22//!   entries, each invoking this binary with a different static
23//!   `--trigger=` argv value baked into the command string. That's
24//!   the only way this binary learns which trigger fired.
25//! - This is observe-only: unlike `PreToolUse`'s `passthrough()`
26//!   (which prints `{}` to mean "no opinion"), `PreCompact` must exit
27//!   0 with **no stdout output at all** — not even `{}`. Any stdout
28//!   here would be interpreted as hook output Claude Code has to
29//!   parse; silence is the correct "no opinion" signal for this hook.
30//!
31//! Every failure mode (malformed stdin, missing MPS env, unreachable
32//! sidecar) degrades to the same outcome: exit 0, nothing printed. A
33//! hook must never block or error the user's Claude session — this
34//! is best-effort observability only, matching the "degrade silently"
35//! philosophy already documented in `mps_client.rs`.
36
37use agentmux_common::client_events::{CompactionStarted, CompactionTrigger};
38use anyhow::Result;
39use serde::Deserialize;
40use std::io::Read;
41
42use crate::mps_client::WpsClient;
43
44/// Which `PreCompact` matcher fired. Baked into argv per hook entry
45/// (see the module doc comment) rather than read from stdin.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
47pub enum Trigger {
48    Manual,
49    Auto,
50}
51
52impl Trigger {
53    fn as_str(self) -> &'static str {
54        match self {
55            Trigger::Manual => "manual",
56            Trigger::Auto => "auto",
57        }
58    }
59}
60
61/// CLI args for `precompact`.
62#[derive(clap::Parser, Debug)]
63pub struct Args {
64    /// Which `PreCompact` matcher fired — `manual` or `auto`. Always
65    /// present: `agent_config.rs` registers one static-argv hook
66    /// entry per matcher value, so this is never inferred at runtime.
67    #[arg(long, value_enum)]
68    pub trigger: Trigger,
69}
70
71/// Subset of the `PreCompact` stdin payload we care about. Every
72/// other field (`transcript_path`, `cwd`, `permission_mode`,
73/// `hook_event_name`) is ignored — serde drops unknown fields by
74/// default, and there's nothing actionable in them for this hook.
75#[derive(Deserialize, Default)]
76struct PreCompactInput {
77    #[serde(default)]
78    session_id: String,
79}
80
81/// Entry point. Always returns `Ok(())` — every failure mode degrades
82/// silently per the module doc comment; there is no error case this
83/// hook should ever surface to Claude Code's stdout.
84pub async fn run(args: Args) -> Result<()> {
85    let mut buf = String::new();
86    // Best-effort stdin read. A read failure just means we publish
87    // with an empty session_id instead of blocking the hook.
88    let _ = std::io::stdin().read_to_string(&mut buf);
89    let session_id = parse_session_id(&buf);
90
91    let Some(client) = WpsClient::from_env() else {
92        tracing::debug!(
93            target: "bashwrap",
94            "precompact: MPS env absent, skipping publish"
95        );
96        return Ok(());
97    };
98
99    // Same fallback as `exec`: prefer an explicit CLI arg (precompact
100    // has none) then AGENTMUX_BLOCKID, inherited from the same spawn
101    // chain agentmux-srv sets up for the whole Claude subprocess tree.
102    let block_id = std::env::var("AGENTMUX_BLOCKID")
103        .ok()
104        .filter(|v| !v.is_empty());
105
106    let payload = build_payload(args.trigger, &session_id);
107
108    tracing::info!(
109        target: "bashwrap",
110        trigger = args.trigger.as_str(),
111        session_id = %session_id,
112        block_id = %block_id.as_deref().unwrap_or(""),
113        "precompact: publishing compaction_started"
114    );
115
116    if let Err(e) = client
117        .publish_compaction_started(block_id.as_deref(), &payload)
118        .await
119    {
120        // Best-effort observability — never fatal, never surfaced to
121        // Claude Code. Just log for local debugging.
122        tracing::warn!(target: "bashwrap", error = %e, "precompact: publish failed");
123    }
124
125    Ok(())
126}
127
128/// Parse `session_id` out of the `PreCompact` stdin payload. Malformed
129/// or empty input yields an empty string rather than erroring — there
130/// is nothing useful to do with the trigger info alone without a
131/// session id, but publishing a best-effort ping is still better than
132/// silently doing nothing.
133fn parse_session_id(stdin_payload: &str) -> String {
134    serde_json::from_str::<PreCompactInput>(stdin_payload)
135        .map(|i| i.session_id)
136        .unwrap_or_default()
137}
138
139/// The `compaction_started` payload (`agentmux_common::client_events`), which srv declares too.
140fn build_payload(trigger: Trigger, session_id: &str) -> CompactionStarted {
141    CompactionStarted {
142        trigger: match trigger {
143            Trigger::Manual => CompactionTrigger::Manual,
144            Trigger::Auto => CompactionTrigger::Auto,
145        },
146        session_id: session_id.to_string(),
147        started_at: chrono::Utc::now().to_rfc3339(),
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use clap::Parser;
155    use crate::test_env_lock::ENV_LOCK;
156
157    // Shared with mps_client.rs's test module (crate::test_env_lock) — both
158    // mutate the SAME process-global env vars under cargo test's default
159    // parallel execution; a private per-module lock didn't actually
160    // synchronize cross-module (Codex P2, PR #2378 round 6).
161    fn clear_env() {
162        std::env::remove_var("AGENTMUX_LOCAL_URL");
163        std::env::remove_var("AGENTMUX_AUTH_KEY");
164        std::env::remove_var("AGENTMUX_BLOCKID");
165    }
166
167    // ── argv parsing ──────────────────────────────────────────────
168
169    #[derive(clap::Parser)]
170    struct TestCli {
171        #[command(flatten)]
172        args: Args,
173    }
174
175    #[test]
176    fn parses_trigger_manual() {
177        let cli = TestCli::parse_from(["precompact", "--trigger=manual"]);
178        assert_eq!(cli.args.trigger, Trigger::Manual);
179    }
180
181    #[test]
182    fn parses_trigger_auto() {
183        let cli = TestCli::parse_from(["precompact", "--trigger=auto"]);
184        assert_eq!(cli.args.trigger, Trigger::Auto);
185    }
186
187    #[test]
188    fn rejects_unknown_trigger_value() {
189        let result = TestCli::try_parse_from(["precompact", "--trigger=sometimes"]);
190        assert!(result.is_err());
191    }
192
193    #[test]
194    fn rejects_missing_trigger() {
195        let result = TestCli::try_parse_from(["precompact"]);
196        assert!(result.is_err());
197    }
198
199    // ── stdin session_id parsing ──────────────────────────────────
200
201    #[test]
202    fn parses_session_id_from_full_precompact_payload() {
203        // Real PreCompact stdin shape: only the common hook fields,
204        // no `trigger`.
205        let payload = serde_json::json!({
206            "session_id": "sess-abc123",
207            "transcript_path": "/tmp/sess-abc123.jsonl",
208            "cwd": "/home/user/project",
209            "permission_mode": "default",
210            "hook_event_name": "PreCompact"
211        })
212        .to_string();
213        assert_eq!(parse_session_id(&payload), "sess-abc123");
214    }
215
216    #[test]
217    fn malformed_stdin_yields_empty_session_id() {
218        assert_eq!(parse_session_id("not json"), "");
219        assert_eq!(parse_session_id(""), "");
220    }
221
222    #[test]
223    fn missing_session_id_field_yields_empty_string() {
224        assert_eq!(parse_session_id(r#"{"cwd":"/tmp"}"#), "");
225    }
226
227    // ── payload shape ──────────────────────────────────────────────
228
229    #[test]
230    fn build_payload_manual_shape() {
231        let payload = build_payload(Trigger::Manual, "sess-1");
232        let v = serde_json::to_value(&payload).unwrap();
233        assert_eq!(v["trigger"], "manual");
234        assert_eq!(v["sessionId"], "sess-1");
235        assert!(v["startedAt"].as_str().unwrap().contains('T'), "expects RFC3339");
236    }
237
238    #[test]
239    fn build_payload_auto_shape() {
240        let payload = build_payload(Trigger::Auto, "sess-2");
241        let v = serde_json::to_value(&payload).unwrap();
242        assert_eq!(v["trigger"], "auto");
243        assert_eq!(v["sessionId"], "sess-2");
244    }
245
246    #[test]
247    fn build_payload_camel_cases_wire_fields() {
248        let payload = build_payload(Trigger::Manual, "");
249        let v = serde_json::to_value(&payload).unwrap();
250        let obj = v.as_object().unwrap();
251        assert!(obj.contains_key("sessionId"));
252        assert!(obj.contains_key("startedAt"));
253        assert!(!obj.contains_key("session_id"), "must be camelCase on the wire");
254    }
255
256    // ── env-driven degrade-gracefully behavior ──────────────────────
257
258    #[test]
259    fn from_env_none_without_wps_env() {
260        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
261        clear_env();
262        // Mirrors run()'s early-return path: no client is built, so
263        // run() would return Ok(()) without attempting a publish.
264        assert!(WpsClient::from_env().is_none());
265        clear_env();
266    }
267
268    #[test]
269    fn block_id_falls_back_to_env_when_unset() {
270        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
271        clear_env();
272        std::env::set_var("AGENTMUX_BLOCKID", "block-42");
273        let block_id = std::env::var("AGENTMUX_BLOCKID")
274            .ok()
275            .filter(|v| !v.is_empty());
276        assert_eq!(block_id.as_deref(), Some("block-42"));
277        clear_env();
278    }
279
280    #[test]
281    fn block_id_empty_env_treated_as_absent() {
282        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
283        clear_env();
284        std::env::set_var("AGENTMUX_BLOCKID", "");
285        let block_id = std::env::var("AGENTMUX_BLOCKID")
286            .ok()
287            .filter(|v| !v.is_empty());
288        assert_eq!(block_id, None);
289        clear_env();
290    }
291}