Skip to main content

agentmux_srv/backend/
shellintegration.rs

1// Copyright 2026, Command Line Inc.
2// Copyright 2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! Shell integration script deployment and shell startup configuration.
6//!
7//! Embeds shell integration scripts (bash, zsh, pwsh, fish) and deploys them to
8//! `~/.agentmux/shell/<type>/` on first use or when the version changes.
9//! The shell controller uses these scripts to install prompt hooks that send
10//! OSC 16162;E commands carrying `AGENTMUX_AGENT_ID`, enabling per-pane title
11//! and color to work.
12
13use std::path::{Path, PathBuf};
14
15// ─── Embedded scripts ────────────────────────────────────────────────────────
16
17const BASH_SCRIPT: &str = include_str!("shellintegration/bash.sh");
18const ZSH_SCRIPT: &str = include_str!("shellintegration/zsh.sh");
19const PWSH_SCRIPT: &str = include_str!("shellintegration/pwsh.ps1");
20const FISH_SCRIPT: &str = include_str!("shellintegration/fish.fish");
21/// Shared muxlog core (Node). Deployed once at `<shell>/muxlog.mjs`; every
22/// shell's `muxlog` function delegates to it. One tested implementation does log
23/// discovery + NDJSON rendering + filtering for all shells.
24const MUXLOG_JS: &str = include_str!("shellintegration/muxlog.mjs");
25/// Shared muxspect core (Node) — muxlog's live-state sibling. Deployed once
26/// at `<shell>/muxspect.mjs`; every shell's `muxspect` function delegates to
27/// it. See docs/specs/SPEC_MUXSPECT_LIVE_INTROSPECTION_TOOL_2026_08_01.md.
28const MUXSPECT_JS: &str = include_str!("shellintegration/muxspect.mjs");
29/// muxopen — launch an agent into a pane from a terminal (the constructive
30/// sibling of the stop verbs; REPORT_AGENT_OPEN_API_GAP_2026_09_06.md).
31/// Deployed beside muxlog.mjs/muxspect.mjs; shell `muxopen` functions
32/// delegate here.
33const MUXOPEN_JS: &str = include_str!("shellintegration/muxopen.mjs");
34/// muxsh — open editor/browser panes from a terminal
35/// (REPORT_WSH_STYLE_CLI_FOR_AGENT_APP_API_2026_09_16.md,
36/// SPEC_MUXSH_CLI_2026_09_16.md). Deployed beside its three siblings; shell
37/// `muxsh` functions delegate here.
38const MUXSH_JS: &str = include_str!("shellintegration/muxsh.mjs");
39/// Shared auth-env-read + authenticated-fetch plumbing every core above
40/// (except muxlog, which does no REST calls) imports via a relative
41/// `./lib/muxclient.mjs` — so it must be deployed at `<shell>/lib/`, not
42/// flat beside its callers. See
43/// docs/specs/SPEC_MUXSH_FULL_COLLECTION_2026_09_16.md §2.8.
44const MUXCLIENT_JS: &str = include_str!("shellintegration/lib/muxclient.mjs");
45
46/// Deployment marker: `<package version>-<content hash>`, NOT the bare
47/// package version alone (codex P2 on PR #2380). Local/dev builds routinely
48/// iterate on these embedded scripts WITHOUT a version bump (this repo's own
49/// convention — see CLAUDE.md's "Build versioning — local builds are
50/// *labeled*, not *versioned*"), so a version-only marker left `.version`
51/// already matching on every same-version rebuild/restart, silently
52/// skipping deployment of a newly-added or newly-edited script (muxspect.mjs
53/// specifically, added in the same PR that added the startup call site —
54/// the marker never invalidated to pick it up). Hashing the actual embedded
55/// content means ANY change to ANY script forces a redeploy regardless of
56/// whether the package version moved, while an unchanged rebuild still
57/// skips the (cheap, but not free) disk writes.
58fn version_marker() -> String {
59    use std::collections::hash_map::DefaultHasher;
60    use std::hash::{Hash, Hasher};
61    let mut hasher = DefaultHasher::new();
62    BASH_SCRIPT.hash(&mut hasher);
63    ZSH_SCRIPT.hash(&mut hasher);
64    PWSH_SCRIPT.hash(&mut hasher);
65    FISH_SCRIPT.hash(&mut hasher);
66    MUXLOG_JS.hash(&mut hasher);
67    MUXSPECT_JS.hash(&mut hasher);
68    // MUXOPEN_JS was missing here (an edit to muxopen.mjs alone never changed
69    // this marker, so a running instance never redeployed it) — fixed while
70    // adding MUXSH_JS below, same class of bug this function's own doc
71    // comment already warns about.
72    MUXOPEN_JS.hash(&mut hasher);
73    MUXSH_JS.hash(&mut hasher);
74    MUXCLIENT_JS.hash(&mut hasher);
75    format!("{}-{:x}", env!("CARGO_PKG_VERSION"), hasher.finish())
76}
77
78// ─── Shell type ──────────────────────────────────────────────────────────────
79
80#[derive(Debug, Clone, Copy, PartialEq)]
81pub enum ShellType {
82    Bash,
83    Zsh,
84    Pwsh,
85    Fish,
86    Unknown,
87}
88
89/// Detect shell type from the shell binary path.
90pub fn detect_shell_type(shell_path: &str) -> ShellType {
91    let name = Path::new(shell_path)
92        .file_stem()
93        .and_then(|s| s.to_str())
94        .unwrap_or("")
95        .to_lowercase();
96
97    match name.as_str() {
98        "pwsh" | "powershell" => ShellType::Pwsh,
99        "bash" => ShellType::Bash,
100        "zsh" => ShellType::Zsh,
101        "fish" => ShellType::Fish,
102        _ => ShellType::Unknown,
103    }
104}
105
106// ─── Deploy ──────────────────────────────────────────────────────────────────
107
108/// Deploy shell integration scripts into `<shell_root>/<type>/`.
109/// Skips deployment if the version marker is already current.
110/// Errors are logged but not fatal — a missing script just means no integration.
111/// Root under which this instance's shell-integration scripts are deployed.
112///
113/// Deliberately under the user's home rather than the instance data dir:
114/// MSIX virtualises writes to `%LocalAppData%`, so scripts written there are
115/// invisible to the ConPTY-spawned children that must source them, while `~`
116/// is never virtualised (see `shell/lifecycle.rs`'s spawn path).
117///
118/// Keyed per instance, because the home dir is otherwise machine-global and
119/// every instance of every version writes these same files — on every shell
120/// spawn, not just at boot. Two concurrently-running versions therefore
121/// rewrite each other's scripts indefinitely, and a shell can source an
122/// rcfile another instance is mid-replacement of. That is invariant **I6**
123/// (instances of different `(channel, version)` never share a directory),
124/// and the key mirrors **I5**'s `dir_hash` treatment of named OS objects.
125///
126/// The data dir is the right key: it is `<channel>/versions/<version>/data`
127/// by construction, so it already distinguishes exactly the pairs I6 names.
128pub fn integration_base_for(data_dir: &Path, home_dir: &Path) -> PathBuf {
129    let canonical = data_dir.canonicalize().unwrap_or_else(|_| data_dir.to_path_buf());
130    let key = format!("{:016x}", agentmux_common::runtime_mode::fnv1a_64(
131        canonical.to_string_lossy().to_lowercase().as_bytes(),
132    ));
133    home_dir.join(".agentmux").join("shell").join(key)
134}
135
136/// The stable, user-facing script root: `~/.agentmux/shell`.
137///
138/// `docs/MUXSPECT.md` and `docs/MUXSH.md` document invoking these directly
139/// (`node ~/.agentmux/shell/muxspect.mjs …`) as the fallback when the shell
140/// function isn't defined. That path has to keep working and has to stay
141/// predictable — a user cannot know their instance's hash — so it is still
142/// deployed, shared, last-writer-wins. Nothing sources it at shell startup;
143/// the rcfiles a terminal actually loads come from `integration_base`.
144pub fn documented_shell_root() -> PathBuf {
145    crate::backend::base::get_mux_data_dir().join("shell")
146}
147
148/// `integration_base_for` bound to this process's real data dir and home.
149///
150/// Ordering note: `get_mux_data_dir()` reads `AGENTMUX_DATA_HOME`, which
151/// `bootstrap::open_stores_and_migrate` overwrites from the resolved
152/// `config.data_home` (CLI `--wavedata` winning over the launcher's env). Until
153/// that runs, the variable may still hold a value INHERITED from whichever
154/// instance spawned this process — observed live: a dev srv started from a
155/// terminal inside a 0.56.2 instance carried that instance's data dir. Both
156/// call sites (bootstrap's own deploy, and the shell spawn in
157/// `blockcontroller/shell/lifecycle.rs`) run after it, so both see the
158/// corrected value. Do not call this earlier in startup without checking that.
159pub fn integration_base() -> PathBuf {
160    integration_base_for(
161        &crate::backend::base::get_mux_data_dir(),
162        &crate::backend::base::get_home_dir(),
163    )
164}
165
166pub fn deploy_scripts(shell_root: &Path) {
167    let shell_base = shell_root.to_path_buf();
168    let version_file = shell_base.join(".version");
169    let marker = version_marker();
170
171    // Check if already up-to-date
172    if let Ok(existing) = std::fs::read_to_string(&version_file) {
173        if existing.trim() == marker {
174            return;
175        }
176    }
177
178    tracing::info!("Deploying shell integration scripts ({})", marker);
179
180    let deploys: &[(&str, &str, &str)] = &[
181        ("bash", ".bashrc", BASH_SCRIPT),
182        ("zsh", ".zshrc", ZSH_SCRIPT),
183        ("pwsh", "wavepwsh.ps1", PWSH_SCRIPT),
184        ("fish", "wave.fish", FISH_SCRIPT),
185    ];
186
187    let mut all_ok = true;
188    for (dir_name, file_name, content) in deploys {
189        let dir = shell_base.join(dir_name);
190        if let Err(e) = std::fs::create_dir_all(&dir) {
191            tracing::warn!("shell integration: failed to create {}: {}", dir.display(), e);
192            all_ok = false;
193            continue;
194        }
195        let path = dir.join(file_name);
196        if let Err(e) = std::fs::write(&path, content) {
197            tracing::warn!("shell integration: failed to write {}: {}", path.display(), e);
198            all_ok = false;
199        }
200    }
201
202    // Deploy the shared muxlog core next to the per-shell dirs. Each rcfile
203    // resolves it relative to its own location and delegates to `node muxlog.mjs`.
204    let muxlog_path = shell_base.join("muxlog.mjs");
205    if let Err(e) = std::fs::write(&muxlog_path, MUXLOG_JS) {
206        tracing::warn!("shell integration: failed to write {}: {}", muxlog_path.display(), e);
207        all_ok = false;
208    }
209
210    // Deploy the shared muxspect core the same way, right next to muxlog.mjs.
211    let muxspect_path = shell_base.join("muxspect.mjs");
212    if let Err(e) = std::fs::write(&muxspect_path, MUXSPECT_JS) {
213        tracing::warn!("shell integration: failed to write {}: {}", muxspect_path.display(), e);
214    }
215    // muxopen deploys the same way, next to its two siblings.
216    let muxopen_path = shell_base.join("muxopen.mjs");
217    if let Err(e) = std::fs::write(&muxopen_path, MUXOPEN_JS) {
218        tracing::warn!("shell integration: failed to write {}: {}", muxopen_path.display(), e);
219        all_ok = false;
220    }
221    // muxsh deploys the same way, next to its three siblings.
222    let muxsh_path = shell_base.join("muxsh.mjs");
223    if let Err(e) = std::fs::write(&muxsh_path, MUXSH_JS) {
224        tracing::warn!("shell integration: failed to write {}: {}", muxsh_path.display(), e);
225        all_ok = false;
226    }
227    // muxclient.mjs is imported via a relative `./lib/muxclient.mjs` from
228    // muxspect/muxopen/muxsh, so it must land in its own lib/ subdirectory,
229    // not flat beside them.
230    let lib_dir = shell_base.join("lib");
231    if let Err(e) = std::fs::create_dir_all(&lib_dir) {
232        tracing::warn!("shell integration: failed to create {}: {}", lib_dir.display(), e);
233        all_ok = false;
234    } else {
235        let muxclient_path = lib_dir.join("muxclient.mjs");
236        if let Err(e) = std::fs::write(&muxclient_path, MUXCLIENT_JS) {
237            tracing::warn!("shell integration: failed to write {}: {}", muxclient_path.display(), e);
238            all_ok = false;
239        }
240    }
241
242    // Write version marker only if all scripts deployed successfully
243    if all_ok {
244        let _ = std::fs::write(&version_file, &marker);
245    }
246}
247
248// ─── Startup configuration ───────────────────────────────────────────────────
249
250/// Shell startup configuration: extra args and env vars to inject.
251pub struct ShellStartup {
252    /// Extra args to append to the shell command.
253    pub extra_args: Vec<String>,
254    /// Environment variables to set in the PTY.
255    pub env_vars: Vec<(String, String)>,
256}
257
258/// Get the startup configuration for launching an interactive shell with
259/// AgentMux integration. Returns `None` for unknown shell types.
260pub fn get_shell_startup(
261    shell_type: ShellType,
262    shell_root: &Path,
263) -> Option<ShellStartup> {
264    match shell_type {
265        ShellType::Bash => {
266            let rcfile = shell_root.join("bash").join(".bashrc");
267            Some(ShellStartup {
268                extra_args: vec![
269                    "--rcfile".to_string(),
270                    rcfile.to_string_lossy().into_owned(),
271                ],
272                env_vars: vec![],
273            })
274        }
275        ShellType::Zsh => {
276            let zdotdir = shell_root.join("zsh");
277            Some(ShellStartup {
278                extra_args: vec![],
279                env_vars: vec![
280                    ("ZDOTDIR".to_string(), zdotdir.to_string_lossy().into_owned()),
281                    // Preserve original ZDOTDIR so the integration script can source ~/.zshrc
282                    ("AGENTMUX_ZDOTDIR".to_string(), zdotdir.to_string_lossy().into_owned()),
283                ],
284            })
285        }
286        ShellType::Pwsh => {
287            let script = shell_root
288                .join("pwsh")
289                .join("wavepwsh.ps1");
290            Some(ShellStartup {
291                extra_args: vec![
292                    "-ExecutionPolicy".to_string(),
293                    "Bypass".to_string(),
294                    "-NoExit".to_string(),
295                    "-File".to_string(),
296                    script.to_string_lossy().into_owned(),
297                ],
298                env_vars: vec![],
299            })
300        }
301        ShellType::Fish => {
302            let script = shell_root
303                .join("fish")
304                .join("wave.fish");
305            Some(ShellStartup {
306                extra_args: vec![
307                    "-C".to_string(),
308                    format!("source {}", agentmux_common::shell_quote::posix_single_quote(&script.to_string_lossy())),
309                ],
310                env_vars: vec![],
311            })
312        }
313        ShellType::Unknown => None,
314    }
315}
316
317// wsh has been retired — see docs/specs/archive/SPEC_RETIRE_WSH_2026_04_12.md.
318// The `AGENTMUX` env var is now a plain "1" sentinel, not a path.
319
320// ─── Helpers ─────────────────────────────────────────────────────────────────
321
322// ─── Tests ───────────────────────────────────────────────────────────────────
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn test_detect_shell_type() {
330        assert_eq!(detect_shell_type("bash"), ShellType::Bash);
331        assert_eq!(detect_shell_type("/bin/bash"), ShellType::Bash);
332        assert_eq!(detect_shell_type("zsh"), ShellType::Zsh);
333        assert_eq!(detect_shell_type("/usr/bin/zsh"), ShellType::Zsh);
334        assert_eq!(detect_shell_type("pwsh"), ShellType::Pwsh);
335        assert_eq!(detect_shell_type("powershell"), ShellType::Pwsh);
336        assert_eq!(detect_shell_type("fish"), ShellType::Fish);
337        assert_eq!(detect_shell_type("cmd.exe"), ShellType::Unknown);
338        assert_eq!(detect_shell_type("cmd"), ShellType::Unknown);
339    }
340
341    #[test]
342    fn test_bash_startup_args() {
343        let dir = Path::new("/home/user/.agentmux");
344        let startup = get_shell_startup(ShellType::Bash, dir).unwrap();
345        assert_eq!(startup.extra_args[0], "--rcfile");
346        assert!(startup.extra_args[1].contains("bash"));
347        assert!(startup.extra_args[1].ends_with(".bashrc"));
348    }
349
350    #[test]
351    fn test_pwsh_startup_args() {
352        let dir = Path::new("/home/user/.agentmux");
353        let startup = get_shell_startup(ShellType::Pwsh, dir).unwrap();
354        assert!(startup.extra_args.contains(&"-NoExit".to_string()));
355        assert!(startup.extra_args.contains(&"-File".to_string()));
356    }
357
358    #[test]
359    fn test_zsh_uses_zdotdir() {
360        let dir = Path::new("/home/user/.agentmux");
361        let startup = get_shell_startup(ShellType::Zsh, dir).unwrap();
362        assert!(startup.extra_args.is_empty());
363        assert!(startup.env_vars.iter().any(|(k, _)| k == "ZDOTDIR"));
364    }
365
366    #[test]
367    fn test_unknown_shell_returns_none() {
368        let dir = Path::new("/tmp");
369        assert!(get_shell_startup(ShellType::Unknown, dir).is_none());
370    }
371
372    /// `muxspect.mjs` deploys next to `muxlog.mjs` — codified as a test since
373    /// it's easy to add a new embedded script and forget the deploy line
374    /// (see docs/specs/SPEC_MUXSPECT_LIVE_INTROSPECTION_TOOL_2026_08_01.md).
375    #[test]
376    fn deploy_scripts_writes_muxlog_and_muxspect_side_by_side() {
377        let tmp = std::env::temp_dir().join(format!(
378            "agentmux-shellintegration-test-{}",
379            std::process::id()
380        ));
381        let _ = std::fs::remove_dir_all(&tmp);
382        deploy_scripts(&tmp.join("shell"));
383
384        let shell_base = tmp.join("shell");
385        let muxlog = shell_base.join("muxlog.mjs");
386        let muxspect = shell_base.join("muxspect.mjs");
387        let muxopen = shell_base.join("muxopen.mjs");
388        let muxsh = shell_base.join("muxsh.mjs");
389        let muxclient = shell_base.join("lib").join("muxclient.mjs");
390        assert!(muxlog.exists(), "muxlog.mjs should be deployed");
391        assert!(muxspect.exists(), "muxspect.mjs should be deployed alongside it");
392        assert!(muxopen.exists(), "muxopen.mjs should be deployed alongside them");
393        assert!(muxsh.exists(), "muxsh.mjs should be deployed alongside them");
394        assert!(
395            muxclient.exists(),
396            "lib/muxclient.mjs should be deployed — muxspect/muxopen/muxsh import it via a relative path"
397        );
398        assert_eq!(std::fs::read_to_string(&muxspect).unwrap(), MUXSPECT_JS);
399        assert_eq!(std::fs::read_to_string(&muxsh).unwrap(), MUXSH_JS);
400        assert_eq!(std::fs::read_to_string(&muxclient).unwrap(), MUXCLIENT_JS);
401
402        let _ = std::fs::remove_dir_all(&tmp);
403    }
404
405    /// codex P2 on PR #2380: a version-only marker left an EXISTING profile
406    /// (same CARGO_PKG_VERSION, older content) permanently skipping
407    /// deployment of a newly-added script — exactly what an existing
408    /// `~/.agentmux` from a prior same-version dev build would have. Content
409    /// hashing must force a redeploy in that case, not just on a genuinely
410    /// fresh profile (the only case the test above covers).
411    #[test]
412    fn deploy_scripts_redeploys_when_marker_predates_a_content_change() {
413        let tmp = std::env::temp_dir().join(format!(
414            "agentmux-shellintegration-test-stale-{}",
415            std::process::id()
416        ));
417        let _ = std::fs::remove_dir_all(&tmp);
418        let shell_base = tmp.join("shell");
419        std::fs::create_dir_all(&shell_base).unwrap();
420
421        // Simulate a profile deployed by an older build of shellintegration.rs
422        // that only ever wrote the bare package version as its marker (i.e.
423        // BEFORE this fix existed) — the exact "same version, no muxspect.mjs
424        // yet" scenario codex's finding describes.
425        std::fs::write(shell_base.join(".version"), env!("CARGO_PKG_VERSION")).unwrap();
426        assert!(!shell_base.join("muxspect.mjs").exists(), "precondition: not deployed yet");
427
428        deploy_scripts(&tmp.join("shell"));
429
430        assert!(
431            shell_base.join("muxspect.mjs").exists(),
432            "a stale version-only marker must not suppress deployment of a script that was never written"
433        );
434        assert_eq!(
435            std::fs::read_to_string(shell_base.join(".version")).unwrap(),
436            version_marker(),
437            "marker must be updated to the new content-hashed form"
438        );
439
440        let _ = std::fs::remove_dir_all(&tmp);
441    }
442
443    // ── Per-instance isolation (I6) ──────────────────────────────────────
444
445    /// THE invariant. Two instances differing only by channel or version must
446    /// never deploy into the same directory — otherwise each shell spawn in
447    /// one rewrites the scripts the other's shells source.
448    #[test]
449    fn two_instances_never_share_a_shell_integration_dir() {
450        let home = Path::new("/home/u");
451        let a = integration_base_for(Path::new("/ch/alpha/versions/0.56.2/data"), home);
452        let b = integration_base_for(Path::new("/ch/alpha/versions/0.56.3/data"), home);
453        let c = integration_base_for(Path::new("/ch/beta/versions/0.56.3/data"), home);
454        assert_ne!(a, b, "same channel, different version must not collide");
455        assert_ne!(b, c, "same version, different channel must not collide");
456        assert_ne!(a, c);
457    }
458
459    /// The path is recomputed on every shell spawn, so an unstable key would
460    /// send a later shell to a directory nothing was deployed into.
461    #[test]
462    fn the_same_instance_always_resolves_to_the_same_dir() {
463        let home = Path::new("/home/u");
464        let d = Path::new("/ch/alpha/versions/0.56.3/data");
465        assert_eq!(integration_base_for(d, home), integration_base_for(d, home));
466    }
467
468    /// Must stay under the home dir: MSIX virtualises `%LocalAppData%`, so a
469    /// data-dir-relative path would be invisible to the shells that source it.
470    #[test]
471    fn deployment_stays_under_the_never_virtualised_home_dir() {
472        let home = Path::new("/home/u");
473        let base = integration_base_for(Path::new("/ch/alpha/versions/0.56.3/data"), home);
474        assert!(base.starts_with(home), "must live under home, got {}", base.display());
475        assert!(!base.starts_with("/ch/alpha"), "must not live under the data dir");
476    }
477
478    /// The coupling that actually matters: whatever `deploy_scripts` writes,
479    /// `get_shell_startup` must point the shell AT. If these two drift apart,
480    /// scripts land where nothing sources them and every terminal silently
481    /// loses shell integration — with no error anywhere. Asserted by resolving
482    /// the startup arg to a real file on disk, not by comparing two strings
483    /// built the same way (which would pass even if both were wrong).
484    #[test]
485    fn the_deployed_rcfile_is_exactly_the_one_the_shell_is_told_to_load() {
486        let tmp = std::env::temp_dir().join(format!("am-si-couple-{}", std::process::id()));
487        let _ = std::fs::remove_dir_all(&tmp);
488        let root = tmp.join("shell");
489        deploy_scripts(&root);
490
491        let startup = get_shell_startup(ShellType::Bash, &root).expect("bash startup");
492        assert_eq!(startup.extra_args[0], "--rcfile");
493        let rcfile = Path::new(&startup.extra_args[1]);
494        assert!(
495            rcfile.exists(),
496            "shell is told to load {} but deploy_scripts never wrote it",
497            rcfile.display()
498        );
499
500        // And it must be the real script, not an empty placeholder.
501        let body = std::fs::read_to_string(rcfile).expect("read rcfile");
502        assert!(body.contains("_agentmux_si_prompt_command"), "rcfile is not the integration script");
503
504        let _ = std::fs::remove_dir_all(&tmp);
505    }
506}