Skip to main content

taskers_runtime/
shell.rs

1use std::{
2    collections::BTreeMap,
3    env, fs,
4    path::{Path, PathBuf},
5};
6
7use anyhow::{Context, Result};
8#[cfg(unix)]
9use libc::{self, passwd};
10#[cfg(unix)]
11use std::{ffi::CStr, os::unix::ffi::OsStringExt};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14enum ShellKind {
15    Bash,
16    Fish,
17    Zsh,
18    Other,
19}
20
21const INHERITED_TERMINAL_ENV_KEYS: &[&str] = &[
22    "TERM",
23    "TERMINFO",
24    "TERMINFO_DIRS",
25    "TERM_PROGRAM",
26    "TERM_PROGRAM_VERSION",
27    "COLORTERM",
28    "NO_COLOR",
29    "CLICOLOR",
30    "CLICOLOR_FORCE",
31    "KITTY_INSTALLATION_DIR",
32    "KITTY_LISTEN_ON",
33    "KITTY_PUBLIC_KEY",
34    "KITTY_WINDOW_ID",
35    "GHOSTTY_BIN_DIR",
36    "GHOSTTY_RESOURCES_DIR",
37    "GHOSTTY_SHELL_FEATURES",
38    "GHOSTTY_SHELL_INTEGRATION_XDG_DIR",
39];
40
41#[derive(Debug, Clone)]
42pub struct ShellIntegration {
43    root: PathBuf,
44    wrapper_path: PathBuf,
45    real_shell: PathBuf,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ShellLaunchSpec {
50    pub program: PathBuf,
51    pub args: Vec<String>,
52    pub env: BTreeMap<String, String>,
53}
54
55impl ShellLaunchSpec {
56    pub fn fallback() -> Self {
57        let program = default_shell_program();
58        let args = match shell_kind(&program) {
59            ShellKind::Fish => vec!["--interactive".into()],
60            ShellKind::Bash | ShellKind::Zsh => vec!["-i".into()],
61            ShellKind::Other => Vec::new(),
62        };
63        Self {
64            program,
65            args,
66            env: base_env(),
67        }
68    }
69
70    pub fn program_and_args(&self) -> Vec<String> {
71        let mut argv = Vec::with_capacity(self.args.len() + 1);
72        argv.push(self.program.display().to_string());
73        argv.extend(self.args.iter().cloned());
74        argv
75    }
76}
77
78impl ShellIntegration {
79    pub fn install(configured_shell: Option<&str>) -> Result<Self> {
80        let root = runtime_root();
81        let wrapper_path = root.join("taskers-shell-wrapper.sh");
82        let real_shell = resolve_shell_program(configured_shell)?;
83
84        install_runtime_assets(&root)?;
85        install_agent_shims(&root)?;
86
87        Ok(Self {
88            root,
89            wrapper_path,
90            real_shell,
91        })
92    }
93
94    pub fn launch_spec(&self) -> ShellLaunchSpec {
95        let profile = std::env::var("TASKERS_SHELL_PROFILE").unwrap_or_else(|_| "default".into());
96        let integration_disabled = std::env::var_os("TASKERS_DISABLE_SHELL_INTEGRATION").is_some();
97
98        match shell_kind(&self.real_shell) {
99            ShellKind::Bash if !integration_disabled => {
100                let mut env = self.base_env();
101                env.insert(
102                    "TASKERS_REAL_SHELL".into(),
103                    self.real_shell.display().to_string(),
104                );
105                env.insert("TASKERS_SHELL_PROFILE".into(), profile);
106                if let Some(value) = std::env::var_os("TASKERS_USER_BASHRC") {
107                    env.insert(
108                        "TASKERS_USER_BASHRC".into(),
109                        value.to_string_lossy().into_owned(),
110                    );
111                }
112
113                ShellLaunchSpec {
114                    program: self.wrapper_path.clone(),
115                    args: Vec::new(),
116                    env,
117                }
118            }
119            ShellKind::Bash => ShellLaunchSpec {
120                program: self.real_shell.clone(),
121                args: vec!["--noprofile".into(), "--norc".into(), "-i".into()],
122                env: self.base_env(),
123            },
124            ShellKind::Fish if !integration_disabled => {
125                let mut env = self.base_env();
126                env.insert(
127                    "TASKERS_REAL_SHELL".into(),
128                    self.real_shell.display().to_string(),
129                );
130                env.insert("TASKERS_SHELL_PROFILE".into(), profile.clone());
131
132                let mut args = Vec::new();
133                if profile == "clean" {
134                    args.push("--no-config".into());
135                }
136                args.push("--interactive".into());
137                args.push("--init-command".into());
138                args.push(fish_source_command());
139
140                ShellLaunchSpec {
141                    program: self.wrapper_path.clone(),
142                    args,
143                    env,
144                }
145            }
146            ShellKind::Fish => ShellLaunchSpec {
147                program: self.real_shell.clone(),
148                args: vec!["--no-config".into(), "--interactive".into()],
149                env: self.base_env(),
150            },
151            ShellKind::Zsh if !integration_disabled => {
152                let mut env = self.base_env();
153                env.insert(
154                    "TASKERS_REAL_SHELL".into(),
155                    self.real_shell.display().to_string(),
156                );
157                env.insert("TASKERS_SHELL_PROFILE".into(), profile.clone());
158                env.insert(
159                    "ZDOTDIR".into(),
160                    zsh_runtime_dir(&self.root).display().to_string(),
161                );
162                if let Some(value) = env::var_os("ZDOTDIR").or_else(|| env::var_os("HOME")) {
163                    env.insert(
164                        "TASKERS_USER_ZDOTDIR".into(),
165                        value.to_string_lossy().into_owned(),
166                    );
167                }
168                let args = if profile == "clean" {
169                    vec!["-d".into(), "-i".into()]
170                } else {
171                    vec!["-i".into()]
172                };
173
174                ShellLaunchSpec {
175                    program: self.wrapper_path.clone(),
176                    args,
177                    env,
178                }
179            }
180            ShellKind::Zsh => ShellLaunchSpec {
181                program: self.real_shell.clone(),
182                args: vec!["-d".into(), "-f".into(), "-i".into()],
183                env: self.base_env(),
184            },
185            ShellKind::Other => {
186                let mut env = self.base_env();
187                env.insert(
188                    "TASKERS_REAL_SHELL".into(),
189                    self.real_shell.display().to_string(),
190                );
191                ShellLaunchSpec {
192                    program: self.wrapper_path.clone(),
193                    args: Vec::new(),
194                    env,
195                }
196            }
197        }
198    }
199
200    pub fn root(&self) -> &Path {
201        &self.root
202    }
203}
204
205impl ShellIntegration {
206    fn base_env(&self) -> BTreeMap<String, String> {
207        let mut env = base_env();
208        env.insert(
209            "TASKERS_SHELL_INTEGRATION_DIR".into(),
210            self.root.display().to_string(),
211        );
212        if let Some(path) = resolve_taskersctl_path() {
213            env.insert("TASKERS_CTL_PATH".into(), path.display().to_string());
214        }
215        let shim_dir = self.root.join("bin");
216        env.insert("PATH".into(), prepend_path_entry(&shim_dir));
217        env
218    }
219}
220
221pub fn install_shell_integration(configured_shell: Option<&str>) -> Result<ShellIntegration> {
222    ShellIntegration::install(configured_shell)
223}
224
225pub fn scrub_inherited_terminal_env() {
226    for key in INHERITED_TERMINAL_ENV_KEYS {
227        unsafe {
228            env::remove_var(key);
229        }
230    }
231}
232
233pub fn default_shell_program() -> PathBuf {
234    login_shell_from_passwd()
235        .or_else(shell_from_env)
236        .unwrap_or_else(|| PathBuf::from("/bin/sh"))
237}
238
239pub fn validate_shell_program(configured_shell: Option<&str>) -> Result<Option<PathBuf>> {
240    configured_shell
241        .and_then(normalize_shell_override)
242        .map(|value| resolve_shell_override(&value))
243        .transpose()
244}
245
246fn base_env() -> BTreeMap<String, String> {
247    let mut env = BTreeMap::new();
248    env.insert("TASKERS_EMBEDDED".into(), "1".into());
249    env.insert("TERM_PROGRAM".into(), "taskers".into());
250    env
251}
252
253fn install_agent_shims(root: &Path) -> Result<()> {
254    let shim_dir = root.join("bin");
255    fs::create_dir_all(&shim_dir)
256        .with_context(|| format!("failed to create {}", shim_dir.display()))?;
257    for (name, target_path) in [
258        ("codex", root.join("taskers-agent-codex.sh")),
259        ("claude", root.join("taskers-agent-claude.sh")),
260        ("claude-code", root.join("taskers-agent-claude.sh")),
261        ("opencode", root.join("taskers-agent-proxy.sh")),
262        ("aider", root.join("taskers-agent-proxy.sh")),
263    ] {
264        let shim_path = shim_dir.join(name);
265        if shim_path.symlink_metadata().is_ok() {
266            fs::remove_file(&shim_path)
267                .with_context(|| format!("failed to replace {}", shim_path.display()))?;
268        }
269
270        #[cfg(unix)]
271        std::os::unix::fs::symlink(&target_path, &shim_path).with_context(|| {
272            format!(
273                "failed to symlink {} -> {}",
274                shim_path.display(),
275                target_path.display()
276            )
277        })?;
278
279        #[cfg(not(unix))]
280        fs::copy(&target_path, &shim_path).with_context(|| {
281            format!(
282                "failed to copy {} -> {}",
283                target_path.display(),
284                shim_path.display()
285            )
286        })?;
287    }
288
289    Ok(())
290}
291
292fn prepend_path_entry(entry: &Path) -> String {
293    let mut parts = vec![entry.display().to_string()];
294    if let Some(path) = env::var_os("PATH") {
295        parts.extend(
296            env::split_paths(&path)
297                .filter(|candidate| candidate != entry)
298                .map(|candidate| candidate.display().to_string()),
299        );
300    }
301    parts.join(":")
302}
303
304fn runtime_root() -> PathBuf {
305    taskers_paths::default_shell_runtime_dir()
306}
307
308fn write_asset(path: &Path, content: &str, executable: bool) -> Result<()> {
309    if let Some(parent) = path.parent() {
310        fs::create_dir_all(parent)
311            .with_context(|| format!("failed to create {}", parent.display()))?;
312    }
313
314    fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?;
315
316    #[cfg(unix)]
317    if executable {
318        use std::os::unix::fs::PermissionsExt;
319
320        let mut permissions = fs::metadata(path)
321            .with_context(|| format!("failed to stat {}", path.display()))?
322            .permissions();
323        permissions.set_mode(0o755);
324        fs::set_permissions(path, permissions)
325            .with_context(|| format!("failed to chmod {}", path.display()))?;
326    }
327
328    Ok(())
329}
330
331fn resolve_taskersctl_path() -> Option<PathBuf> {
332    if let Some(path) = std::env::current_exe()
333        .ok()
334        .and_then(|path| path.parent().map(|parent| parent.join("taskersctl")))
335        .filter(|path| path.is_file())
336    {
337        return Some(path);
338    }
339
340    if let Some(path) = env::var_os("TASKERS_CTL_PATH")
341        .map(PathBuf::from)
342        .filter(|path| path.is_file())
343    {
344        return Some(path);
345    }
346
347    if let Some(home) = env::var_os("HOME").map(PathBuf::from) {
348        for candidate in [
349            home.join(".cargo").join("bin").join("taskersctl"),
350            home.join(".local").join("bin").join("taskersctl"),
351        ] {
352            if candidate.is_file() {
353                return Some(candidate);
354            }
355        }
356    }
357
358    let path_var = env::var_os("PATH")?;
359    env::split_paths(&path_var)
360        .map(|entry| entry.join("taskersctl"))
361        .find(|candidate| candidate.is_file())
362}
363
364fn resolve_shell_program(configured_shell: Option<&str>) -> Result<PathBuf> {
365    if let Some(shell) = configured_shell.and_then(|value| normalize_shell_override(value)) {
366        return resolve_shell_override(&shell)
367            .with_context(|| format!("failed to resolve configured shell {shell}"));
368    }
369
370    Ok(default_shell_program())
371}
372
373fn shell_kind(path: &Path) -> ShellKind {
374    let name = path
375        .file_name()
376        .and_then(|value| value.to_str())
377        .unwrap_or_default()
378        .trim_start_matches('-');
379
380    match name {
381        "bash" => ShellKind::Bash,
382        "fish" => ShellKind::Fish,
383        "zsh" => ShellKind::Zsh,
384        _ => ShellKind::Other,
385    }
386}
387
388fn normalize_shell_override(value: &str) -> Option<String> {
389    let trimmed = value.trim();
390    if trimmed.is_empty() {
391        None
392    } else {
393        Some(trimmed.to_string())
394    }
395}
396
397fn resolve_shell_override(value: &str) -> Result<PathBuf> {
398    let expanded = expand_home_prefix(value);
399    let candidate = PathBuf::from(&expanded);
400    if expanded.contains('/') {
401        anyhow::ensure!(
402            candidate.is_file(),
403            "shell program {} does not exist",
404            candidate.display()
405        );
406        return Ok(candidate);
407    }
408
409    let path_var = env::var_os("PATH").unwrap_or_default();
410    let resolved = env::split_paths(&path_var)
411        .map(|entry| entry.join(&candidate))
412        .find(|entry| entry.is_file());
413    resolved.with_context(|| format!("shell program {value} was not found in PATH"))
414}
415
416fn expand_home_prefix(value: &str) -> String {
417    if value == "~" {
418        return env::var("HOME").unwrap_or_else(|_| value.to_string());
419    }
420
421    if let Some(suffix) = value.strip_prefix("~/") {
422        if let Some(home) = env::var_os("HOME") {
423            return PathBuf::from(home).join(suffix).display().to_string();
424        }
425    }
426
427    value.to_string()
428}
429
430fn shell_from_env() -> Option<PathBuf> {
431    env::var_os("SHELL")
432        .map(PathBuf::from)
433        .filter(|path| !path.as_os_str().is_empty())
434}
435
436#[cfg(unix)]
437fn login_shell_from_passwd() -> Option<PathBuf> {
438    let uid = unsafe { libc::geteuid() };
439    let mut pwd = std::mem::MaybeUninit::<passwd>::uninit();
440    let mut result = std::ptr::null_mut::<passwd>();
441    let mut buffer = vec![0u8; passwd_buffer_size()];
442
443    let status = unsafe {
444        libc::getpwuid_r(
445            uid,
446            pwd.as_mut_ptr(),
447            buffer.as_mut_ptr().cast(),
448            buffer.len(),
449            &mut result,
450        )
451    };
452    if status != 0 || result.is_null() {
453        return None;
454    }
455
456    let pwd = unsafe { pwd.assume_init() };
457    if pwd.pw_shell.is_null() {
458        return None;
459    }
460
461    let shell = unsafe { CStr::from_ptr(pwd.pw_shell) }.to_bytes().to_vec();
462    if shell.is_empty() {
463        return None;
464    }
465
466    Some(PathBuf::from(std::ffi::OsString::from_vec(shell)))
467}
468
469#[cfg(not(unix))]
470fn login_shell_from_passwd() -> Option<PathBuf> {
471    None
472}
473
474#[cfg(unix)]
475fn passwd_buffer_size() -> usize {
476    let size = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
477    if size <= 0 { 4096 } else { size as usize }
478}
479
480#[cfg(not(unix))]
481fn passwd_buffer_size() -> usize {
482    4096
483}
484
485fn fish_source_command() -> String {
486    r#"source "$TASKERS_SHELL_INTEGRATION_DIR/taskers-hooks.fish""#.into()
487}
488
489fn zsh_runtime_dir(root: &Path) -> PathBuf {
490    root.join("zsh")
491}
492
493fn install_runtime_assets(root: &Path) -> Result<()> {
494    write_asset(
495        &root.join("taskers-shell-wrapper.sh"),
496        include_str!(concat!(
497            env!("CARGO_MANIFEST_DIR"),
498            "/assets/shell/taskers-shell-wrapper.sh"
499        )),
500        true,
501    )?;
502    write_asset(
503        &root.join("bash").join("taskers.bashrc"),
504        include_str!(concat!(
505            env!("CARGO_MANIFEST_DIR"),
506            "/assets/shell/bash/taskers.bashrc"
507        )),
508        false,
509    )?;
510    write_asset(
511        &root.join("taskers-hooks.bash"),
512        include_str!(concat!(
513            env!("CARGO_MANIFEST_DIR"),
514            "/assets/shell/taskers-hooks.bash"
515        )),
516        false,
517    )?;
518    write_asset(
519        &root.join("taskers-hooks.fish"),
520        include_str!(concat!(
521            env!("CARGO_MANIFEST_DIR"),
522            "/assets/shell/taskers-hooks.fish"
523        )),
524        false,
525    )?;
526    write_asset(
527        &root.join("taskers-hooks.zsh"),
528        include_str!(concat!(
529            env!("CARGO_MANIFEST_DIR"),
530            "/assets/shell/taskers-hooks.zsh"
531        )),
532        false,
533    )?;
534    write_asset(
535        &zsh_runtime_dir(root).join(".zshenv"),
536        include_str!(concat!(
537            env!("CARGO_MANIFEST_DIR"),
538            "/assets/shell/zsh/.zshenv"
539        )),
540        false,
541    )?;
542    write_asset(
543        &zsh_runtime_dir(root).join(".zshrc"),
544        include_str!(concat!(
545            env!("CARGO_MANIFEST_DIR"),
546            "/assets/shell/zsh/.zshrc"
547        )),
548        false,
549    )?;
550    write_asset(
551        &zsh_runtime_dir(root).join(".zcompdump"),
552        include_str!(concat!(
553            env!("CARGO_MANIFEST_DIR"),
554            "/assets/shell/zsh/.zcompdump"
555        )),
556        false,
557    )?;
558    write_asset(
559        &root.join("taskers-codex-notify.sh"),
560        include_str!(concat!(
561            env!("CARGO_MANIFEST_DIR"),
562            "/assets/shell/taskers-codex-notify.sh"
563        )),
564        true,
565    )?;
566    write_asset(
567        &root.join("taskers-claude-hook.sh"),
568        include_str!(concat!(
569            env!("CARGO_MANIFEST_DIR"),
570            "/assets/shell/taskers-claude-hook.sh"
571        )),
572        true,
573    )?;
574    write_asset(
575        &root.join("taskers-agent-codex.sh"),
576        include_str!(concat!(
577            env!("CARGO_MANIFEST_DIR"),
578            "/assets/shell/taskers-agent-codex.sh"
579        )),
580        true,
581    )?;
582    write_asset(
583        &root.join("taskers-agent-claude.sh"),
584        include_str!(concat!(
585            env!("CARGO_MANIFEST_DIR"),
586            "/assets/shell/taskers-agent-claude.sh"
587        )),
588        true,
589    )?;
590    write_asset(
591        &root.join("taskers-agent-proxy.sh"),
592        include_str!(concat!(
593            env!("CARGO_MANIFEST_DIR"),
594            "/assets/shell/taskers-agent-proxy.sh"
595        )),
596        true,
597    )?;
598    Ok(())
599}
600
601#[cfg(test)]
602mod tests {
603    use std::{
604        fs,
605        path::PathBuf,
606        process::Command,
607        sync::Mutex,
608        time::{Duration, SystemTime},
609    };
610
611    #[cfg(unix)]
612    use std::os::unix::fs::PermissionsExt;
613
614    use super::{
615        INHERITED_TERMINAL_ENV_KEYS, ShellIntegration, expand_home_prefix, fish_source_command,
616        install_runtime_assets, normalize_shell_override, resolve_shell_override, zsh_runtime_dir,
617    };
618    use crate::{CommandSpec, PtySession};
619
620    static ENV_LOCK: Mutex<()> = Mutex::new(());
621
622    #[test]
623    fn shell_override_normalizes_blank_values() {
624        assert_eq!(normalize_shell_override(""), None);
625        assert_eq!(normalize_shell_override("   "), None);
626        assert_eq!(
627            normalize_shell_override(" /usr/bin/fish "),
628            Some("/usr/bin/fish".into())
629        );
630    }
631
632    #[test]
633    fn fish_source_command_uses_runtime_env_path() {
634        assert_eq!(
635            fish_source_command(),
636            r#"source "$TASKERS_SHELL_INTEGRATION_DIR/taskers-hooks.fish""#
637        );
638    }
639
640    #[test]
641    fn zsh_launch_spec_routes_through_runtime_zdotdir() {
642        let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
643        let original_zdotdir = std::env::var_os("ZDOTDIR");
644        let original_home = std::env::var_os("HOME");
645        unsafe {
646            std::env::set_var("HOME", "/tmp/taskers-home");
647            std::env::set_var("ZDOTDIR", "/tmp/user-zdotdir");
648            std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
649            std::env::remove_var("TASKERS_SHELL_PROFILE");
650        }
651
652        let integration = ShellIntegration {
653            root: PathBuf::from("/tmp/taskers-runtime"),
654            wrapper_path: PathBuf::from("/tmp/taskers-runtime/taskers-shell-wrapper.sh"),
655            real_shell: PathBuf::from("/usr/bin/zsh"),
656        };
657        let spec = integration.launch_spec();
658
659        assert_eq!(
660            spec.env.get("ZDOTDIR").map(String::as_str),
661            Some("/tmp/taskers-runtime/zsh")
662        );
663        assert_eq!(
664            spec.env.get("TASKERS_USER_ZDOTDIR").map(String::as_str),
665            Some("/tmp/user-zdotdir")
666        );
667        assert_eq!(spec.program, integration.wrapper_path);
668        assert_eq!(spec.args, vec!["-i"]);
669
670        unsafe {
671            match original_zdotdir {
672                Some(value) => std::env::set_var("ZDOTDIR", value),
673                None => std::env::remove_var("ZDOTDIR"),
674            }
675            match original_home {
676                Some(value) => std::env::set_var("HOME", value),
677                None => std::env::remove_var("HOME"),
678            }
679        }
680    }
681
682    #[test]
683    fn zsh_runtime_dir_is_nested_under_runtime_root() {
684        assert_eq!(
685            zsh_runtime_dir(&PathBuf::from("/tmp/taskers-runtime")),
686            PathBuf::from("/tmp/taskers-runtime/zsh")
687        );
688    }
689
690    #[test]
691    fn install_runtime_assets_writes_zsh_runtime_files() {
692        let root = unique_temp_dir("taskers-runtime-test");
693        install_runtime_assets(&root).expect("install runtime assets");
694
695        assert!(root.join("taskers-shell-wrapper.sh").is_file());
696        assert!(root.join("taskers-hooks.bash").is_file());
697        assert!(root.join("taskers-hooks.fish").is_file());
698        assert!(root.join("taskers-hooks.zsh").is_file());
699        assert!(root.join("taskers-codex-notify.sh").is_file());
700        assert!(root.join("taskers-claude-hook.sh").is_file());
701        assert!(root.join("taskers-agent-codex.sh").is_file());
702        assert!(root.join("taskers-agent-claude.sh").is_file());
703        assert!(root.join("taskers-agent-proxy.sh").is_file());
704        assert!(zsh_runtime_dir(&root).join(".zshenv").is_file());
705        assert!(zsh_runtime_dir(&root).join(".zshrc").is_file());
706        assert!(zsh_runtime_dir(&root).join(".zcompdump").is_file());
707
708        fs::remove_dir_all(&root).expect("cleanup runtime assets");
709    }
710
711    #[test]
712    fn home_prefix_expansion_without_home_keeps_original_shape() {
713        let original = "~/bin/fish";
714        let expanded = expand_home_prefix(original);
715        if std::env::var_os("HOME").is_some() {
716            assert_ne!(expanded, original);
717        } else {
718            assert_eq!(expanded, original);
719        }
720    }
721
722    #[test]
723    fn inherited_terminal_env_keys_cover_color_and_terminfo_leaks() {
724        for key in ["NO_COLOR", "TERMINFO", "TERMINFO_DIRS", "TERM_PROGRAM"] {
725            assert!(
726                INHERITED_TERMINAL_ENV_KEYS.contains(&key),
727                "expected {key} to be scrubbed from inherited terminal env"
728            );
729        }
730    }
731
732    #[test]
733    fn shell_wrapper_exports_taskers_tty_name() {
734        let wrapper = include_str!(concat!(
735            env!("CARGO_MANIFEST_DIR"),
736            "/assets/shell/taskers-shell-wrapper.sh"
737        ));
738        assert!(
739            wrapper.contains("TASKERS_TTY_NAME"),
740            "expected wrapper to export TASKERS_TTY_NAME"
741        );
742    }
743
744    #[test]
745    fn shell_wrapper_routes_terminal_sessions_through_sidecar_attach() {
746        let wrapper = include_str!(concat!(
747            env!("CARGO_MANIFEST_DIR"),
748            "/assets/shell/taskers-shell-wrapper.sh"
749        ));
750        assert!(
751            wrapper.contains("TASKERS_TERMINAL_SOCKET"),
752            "expected wrapper to branch on terminal socket availability"
753        );
754        assert!(
755            wrapper.contains("TASKERS_TERMINAL_SESSION_ID"),
756            "expected wrapper to require terminal session ids for session attach"
757        );
758        assert!(
759            wrapper.contains("session attach"),
760            "expected wrapper to delegate continuity startup to taskersctl session attach"
761        );
762    }
763
764    #[test]
765    fn shell_wrapper_handles_fish_and_zsh_default_launch_modes() {
766        let wrapper = include_str!(concat!(
767            env!("CARGO_MANIFEST_DIR"),
768            "/assets/shell/taskers-shell-wrapper.sh"
769        ));
770        assert!(
771            wrapper.contains("SHELL_PROFILE=${TASKERS_SHELL_PROFILE:-default}"),
772            "expected wrapper to honor TASKERS_SHELL_PROFILE when synthesizing default shell args"
773        );
774        assert!(
775            wrapper.contains("--init-command"),
776            "expected wrapper to synthesize fish init-command integration when no explicit args are passed"
777        );
778        assert!(
779            wrapper.contains("set -- -d -i"),
780            "expected wrapper to synthesize zsh default launch flags when no explicit args are passed"
781        );
782        assert!(
783            wrapper.contains("set -- --no-config --interactive --init-command"),
784            "expected clean-profile fish launches to keep sourcing taskers-hooks.fish"
785        );
786    }
787
788    #[test]
789    fn fish_and_zsh_launch_specs_preserve_shell_profile_env() {
790        let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
791        let original_profile = std::env::var_os("TASKERS_SHELL_PROFILE");
792        let original_disabled = std::env::var_os("TASKERS_DISABLE_SHELL_INTEGRATION");
793        unsafe {
794            std::env::set_var("TASKERS_SHELL_PROFILE", "clean");
795            std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
796        }
797
798        let fish_integration = ShellIntegration {
799            root: PathBuf::from("/tmp/taskers-runtime"),
800            wrapper_path: PathBuf::from("/tmp/taskers-runtime/taskers-shell-wrapper.sh"),
801            real_shell: PathBuf::from("/usr/bin/fish"),
802        };
803        let zsh_integration = ShellIntegration {
804            root: PathBuf::from("/tmp/taskers-runtime"),
805            wrapper_path: PathBuf::from("/tmp/taskers-runtime/taskers-shell-wrapper.sh"),
806            real_shell: PathBuf::from("/usr/bin/zsh"),
807        };
808
809        let fish_spec = fish_integration.launch_spec();
810        let zsh_spec = zsh_integration.launch_spec();
811
812        assert_eq!(
813            fish_spec
814                .env
815                .get("TASKERS_SHELL_PROFILE")
816                .map(String::as_str),
817            Some("clean")
818        );
819        assert_eq!(
820            zsh_spec
821                .env
822                .get("TASKERS_SHELL_PROFILE")
823                .map(String::as_str),
824            Some("clean")
825        );
826
827        unsafe {
828            match original_profile {
829                Some(value) => std::env::set_var("TASKERS_SHELL_PROFILE", value),
830                None => std::env::remove_var("TASKERS_SHELL_PROFILE"),
831            }
832            match original_disabled {
833                Some(value) => std::env::set_var("TASKERS_DISABLE_SHELL_INTEGRATION", value),
834                None => std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION"),
835            }
836        }
837    }
838
839    #[test]
840    fn shell_hooks_and_proxy_require_surface_tty_identity() {
841        let bash_hooks = include_str!(concat!(
842            env!("CARGO_MANIFEST_DIR"),
843            "/assets/shell/taskers-hooks.bash"
844        ));
845        let zsh_hooks = include_str!(concat!(
846            env!("CARGO_MANIFEST_DIR"),
847            "/assets/shell/taskers-hooks.zsh"
848        ));
849        let fish_hooks = include_str!(concat!(
850            env!("CARGO_MANIFEST_DIR"),
851            "/assets/shell/taskers-hooks.fish"
852        ));
853        let agent_proxy = include_str!(concat!(
854            env!("CARGO_MANIFEST_DIR"),
855            "/assets/shell/taskers-agent-proxy.sh"
856        ));
857
858        for asset in [bash_hooks, zsh_hooks, fish_hooks] {
859            assert!(
860                asset.contains("TASKERS_SURFACE_ID"),
861                "expected asset to require TASKERS_SURFACE_ID"
862            );
863            assert!(
864                asset.contains("TASKERS_TTY_NAME"),
865                "expected asset to require TASKERS_TTY_NAME"
866            );
867        }
868        assert!(
869            agent_proxy.contains("TASKERS_AGENT_PROXY_ACTIVE"),
870            "expected proxy asset to keep loop-prevention guard"
871        );
872    }
873
874    #[test]
875    fn shell_hooks_only_treat_agent_identity_as_live_process_state() {
876        let bash_hooks = include_str!(concat!(
877            env!("CARGO_MANIFEST_DIR"),
878            "/assets/shell/taskers-hooks.bash"
879        ));
880        let zsh_hooks = include_str!(concat!(
881            env!("CARGO_MANIFEST_DIR"),
882            "/assets/shell/taskers-hooks.zsh"
883        ));
884        let fish_hooks = include_str!(concat!(
885            env!("CARGO_MANIFEST_DIR"),
886            "/assets/shell/taskers-hooks.fish"
887        ));
888
889        for asset in [bash_hooks, zsh_hooks, fish_hooks] {
890            assert!(
891                !asset.contains("TASKERS_PANE_AGENT_KIND"),
892                "expected hook asset to avoid sticky pane-level agent identity"
893            );
894        }
895    }
896
897    #[test]
898    fn shell_assets_do_not_auto_report_completed_on_clean_or_interrupted_exit() {
899        let bash_hooks = include_str!(concat!(
900            env!("CARGO_MANIFEST_DIR"),
901            "/assets/shell/taskers-hooks.bash"
902        ));
903        let zsh_hooks = include_str!(concat!(
904            env!("CARGO_MANIFEST_DIR"),
905            "/assets/shell/taskers-hooks.zsh"
906        ));
907        let fish_hooks = include_str!(concat!(
908            env!("CARGO_MANIFEST_DIR"),
909            "/assets/shell/taskers-hooks.fish"
910        ));
911        let agent_proxy = include_str!(concat!(
912            env!("CARGO_MANIFEST_DIR"),
913            "/assets/shell/taskers-agent-proxy.sh"
914        ));
915
916        for asset in [bash_hooks, zsh_hooks, fish_hooks] {
917            assert!(
918                !asset.contains("taskers__emit_with_metadata completed"),
919                "expected hook asset to avoid auto-emitting completed on bare agent exit"
920            );
921        }
922        assert!(
923            !agent_proxy.contains("emit_signal completed"),
924            "expected proxy to avoid auto-emitting completed on bare agent exit"
925        );
926        assert!(
927            !agent_proxy.contains("emit_signal error"),
928            "expected proxy to avoid owning stop/error signaling"
929        );
930    }
931
932    #[test]
933    fn zsh_shell_hook_avoids_readonly_status_parameter_name() {
934        let zsh_hooks = include_str!(concat!(
935            env!("CARGO_MANIFEST_DIR"),
936            "/assets/shell/taskers-hooks.zsh"
937        ));
938
939        assert!(
940            !zsh_hooks.contains("local status="),
941            "expected zsh hooks to avoid assigning to zsh's readonly status parameter"
942        );
943    }
944
945    #[test]
946    fn zsh_shell_hook_tracks_directory_changes_for_metadata() {
947        let zsh_hooks = include_str!(concat!(
948            env!("CARGO_MANIFEST_DIR"),
949            "/assets/shell/taskers-hooks.zsh"
950        ));
951
952        assert!(
953            zsh_hooks.contains("add-zsh-hook chpwd taskers__on_chpwd")
954                || zsh_hooks.contains("chpwd_functions+=(taskers__on_chpwd)"),
955            "expected zsh hooks to refresh metadata on directory changes"
956        );
957    }
958
959    #[test]
960    fn zsh_shell_hook_prefers_shell_tty_and_supports_jj_repos() {
961        let zsh_hooks = include_str!(concat!(
962            env!("CARGO_MANIFEST_DIR"),
963            "/assets/shell/taskers-hooks.zsh"
964        ));
965
966        assert!(
967            zsh_hooks.contains("local current_tty=${TTY:-}"),
968            "expected zsh hooks to prefer zsh's built-in TTY variable"
969        );
970        assert!(
971            zsh_hooks.contains("jj root"),
972            "expected zsh hooks to support JJ repo root detection"
973        );
974    }
975
976    #[test]
977    fn shell_hooks_emit_boolean_agent_active_flags() {
978        let bash_hooks = include_str!(concat!(
979            env!("CARGO_MANIFEST_DIR"),
980            "/assets/shell/taskers-hooks.bash"
981        ));
982        let zsh_hooks = include_str!(concat!(
983            env!("CARGO_MANIFEST_DIR"),
984            "/assets/shell/taskers-hooks.zsh"
985        ));
986        let fish_hooks = include_str!(concat!(
987            env!("CARGO_MANIFEST_DIR"),
988            "/assets/shell/taskers-hooks.fish"
989        ));
990
991        for asset in [bash_hooks, zsh_hooks, fish_hooks] {
992            assert!(
993                asset.contains("true"),
994                "expected hook asset to emit literal boolean true"
995            );
996            assert!(
997                asset.contains("false"),
998                "expected hook asset to emit literal boolean false"
999            );
1000        }
1001    }
1002
1003    #[test]
1004    fn embedded_zsh_emits_metadata_for_repo_cwd() {
1005        let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1006        let runtime_root = unique_temp_dir("taskers-runtime-zsh-metadata");
1007        install_runtime_assets(&runtime_root).expect("install runtime assets");
1008
1009        let home_dir = runtime_root.join("home");
1010        let real_bin_dir = runtime_root.join("real-bin");
1011        let repo_dir = runtime_root.join("repo");
1012        fs::create_dir_all(&home_dir).expect("home dir");
1013        fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1014        fs::create_dir_all(&repo_dir).expect("repo dir");
1015
1016        let taskersctl_path = runtime_root.join("taskersctl");
1017        let test_log = runtime_root.join("taskersctl.log");
1018        write_executable(
1019            &taskersctl_path,
1020            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1021        );
1022        write_executable(
1023            &real_bin_dir.join("git"),
1024            "#!/bin/sh\ncwd=\nif [ \"$1\" = \"-C\" ]; then cwd=$2; shift 2; fi\nif [ \"$1\" = \"rev-parse\" ] && [ \"$2\" = \"--show-toplevel\" ]; then printf '%s\\n' \"$cwd\"; exit 0; fi\nif [ \"$1\" = \"symbolic-ref\" ] && [ \"$2\" = \"--quiet\" ] && [ \"$3\" = \"--short\" ] && [ \"$4\" = \"HEAD\" ]; then printf 'main\\n'; exit 0; fi\nif [ \"$1\" = \"rev-parse\" ] && [ \"$2\" = \"--short\" ] && [ \"$3\" = \"HEAD\" ]; then printf 'abc123\\n'; exit 0; fi\nexit 1\n",
1025        );
1026
1027        let original_home = std::env::var_os("HOME");
1028        let original_path = std::env::var_os("PATH");
1029        let original_zdotdir = std::env::var_os("ZDOTDIR");
1030        let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1031        unsafe {
1032            std::env::set_var("HOME", &home_dir);
1033            std::env::remove_var("ZDOTDIR");
1034            std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1035            std::env::remove_var("TASKERS_SHELL_PROFILE");
1036            std::env::set_var(
1037                "PATH",
1038                format!(
1039                    "{}:{}",
1040                    real_bin_dir.display(),
1041                    original_path
1042                        .as_deref()
1043                        .map(|value| value.to_string_lossy().into_owned())
1044                        .unwrap_or_default()
1045                ),
1046            );
1047        }
1048
1049        let integration = ShellIntegration {
1050            root: runtime_root.clone(),
1051            wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1052            real_shell: zsh_path,
1053        };
1054        let mut launch = integration.launch_spec();
1055        launch.env.insert(
1056            "TASKERS_CTL_PATH".into(),
1057            taskersctl_path.display().to_string(),
1058        );
1059        launch
1060            .env
1061            .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1062        launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1063        launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1064        launch
1065            .env
1066            .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1067
1068        let mut spec = CommandSpec::new(launch.program.display().to_string());
1069        spec.args = launch.args;
1070        spec.env = launch.env;
1071        spec.cwd = Some(repo_dir.clone());
1072
1073        let mut spawned = PtySession::spawn(&spec).expect("spawn shell");
1074        std::thread::sleep(Duration::from_millis(250));
1075        spawned.session.write_all(b"exit\n").expect("exit shell");
1076
1077        let mut reader = spawned.reader;
1078        let mut buffer = [0u8; 1024];
1079        while reader.read_into(&mut buffer).unwrap_or(0) > 0 {}
1080
1081        let log = fs::read_to_string(&test_log).expect("read metadata log");
1082        assert!(
1083            log.contains("signal --source shell --kind metadata"),
1084            "expected zsh shell to emit metadata, got: {log}"
1085        );
1086        assert!(
1087            log.contains(&format!("--cwd {}", repo_dir.display())),
1088            "expected metadata cwd in log, got: {log}"
1089        );
1090        assert!(
1091            log.contains("--repo repo"),
1092            "expected repo name in log, got: {log}"
1093        );
1094        assert!(
1095            log.contains("--branch main"),
1096            "expected git branch in log, got: {log}"
1097        );
1098
1099        restore_env_var("HOME", original_home);
1100        restore_env_var("PATH", original_path);
1101        restore_env_var("ZDOTDIR", original_zdotdir);
1102        fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1103    }
1104
1105    #[test]
1106    fn embedded_zsh_falls_back_to_jj_branch_when_git_probe_fails() {
1107        let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1108        let runtime_root = unique_temp_dir("taskers-runtime-zsh-jj-fallback");
1109        install_runtime_assets(&runtime_root).expect("install runtime assets");
1110
1111        let home_dir = runtime_root.join("home");
1112        let real_bin_dir = runtime_root.join("real-bin");
1113        let repo_dir = runtime_root.join("repo");
1114        fs::create_dir_all(&home_dir).expect("home dir");
1115        fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1116        fs::create_dir_all(&repo_dir).expect("repo dir");
1117
1118        let taskersctl_path = runtime_root.join("taskersctl");
1119        let test_log = runtime_root.join("taskersctl.log");
1120        write_executable(
1121            &taskersctl_path,
1122            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1123        );
1124        write_executable(&real_bin_dir.join("git"), "#!/bin/sh\nexit 1\n");
1125        write_executable(
1126            &real_bin_dir.join("jj"),
1127            &format!(
1128                "#!/bin/sh\nif [ \"$1\" = \"root\" ]; then printf '%s\\n' \"{}\"; exit 0; fi\nif [ \"$1\" = \"log\" ]; then printf 'jj123456\\n'; exit 0; fi\nexit 1\n",
1129                repo_dir.display()
1130            ),
1131        );
1132
1133        let original_home = std::env::var_os("HOME");
1134        let original_path = std::env::var_os("PATH");
1135        let original_zdotdir = std::env::var_os("ZDOTDIR");
1136        let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1137        unsafe {
1138            std::env::set_var("HOME", &home_dir);
1139            std::env::remove_var("ZDOTDIR");
1140            std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1141            std::env::remove_var("TASKERS_SHELL_PROFILE");
1142            std::env::set_var(
1143                "PATH",
1144                format!(
1145                    "{}:{}",
1146                    real_bin_dir.display(),
1147                    original_path
1148                        .as_deref()
1149                        .map(|value| value.to_string_lossy().into_owned())
1150                        .unwrap_or_default()
1151                ),
1152            );
1153        }
1154
1155        let integration = ShellIntegration {
1156            root: runtime_root.clone(),
1157            wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1158            real_shell: zsh_path,
1159        };
1160        let mut launch = integration.launch_spec();
1161        launch.env.insert(
1162            "TASKERS_CTL_PATH".into(),
1163            taskersctl_path.display().to_string(),
1164        );
1165        launch
1166            .env
1167            .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1168        launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1169        launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1170        launch
1171            .env
1172            .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1173
1174        let mut spec = CommandSpec::new(launch.program.display().to_string());
1175        spec.args = launch.args;
1176        spec.env = launch.env;
1177        spec.cwd = Some(repo_dir.clone());
1178
1179        let mut spawned = PtySession::spawn(&spec).expect("spawn shell");
1180        std::thread::sleep(Duration::from_millis(250));
1181        spawned.session.write_all(b"exit\n").expect("exit shell");
1182
1183        let mut reader = spawned.reader;
1184        let mut buffer = [0u8; 1024];
1185        while reader.read_into(&mut buffer).unwrap_or(0) > 0 {}
1186
1187        let log = fs::read_to_string(&test_log).expect("read metadata log");
1188        assert!(
1189            log.contains("signal --source shell --kind metadata"),
1190            "expected zsh shell to emit metadata, got: {log}"
1191        );
1192        assert!(
1193            log.contains(&format!("--cwd {}", repo_dir.display())),
1194            "expected metadata cwd in log, got: {log}"
1195        );
1196        assert!(
1197            log.contains("--repo repo"),
1198            "expected repo name in log, got: {log}"
1199        );
1200        assert!(
1201            log.contains("--branch jj123456"),
1202            "expected JJ branch fallback in log, got: {log}"
1203        );
1204
1205        restore_env_var("HOME", original_home);
1206        restore_env_var("PATH", original_path);
1207        restore_env_var("ZDOTDIR", original_zdotdir);
1208        fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1209    }
1210
1211    #[test]
1212    fn bash_shell_hook_marks_prompt_only_once_until_preexec_runs() {
1213        let bash_hooks = include_str!(concat!(
1214            env!("CARGO_MANIFEST_DIR"),
1215            "/assets/shell/taskers-hooks.bash"
1216        ));
1217
1218        assert!(
1219            bash_hooks.contains("TASKERS_OSC133_PROMPT_MARKED"),
1220            "expected bash hooks to track whether the prompt is already OSC133-marked"
1221        );
1222        assert!(
1223            bash_hooks.contains("TASKERS_OSC133_SAVE_PS1:-"),
1224            "expected bash hooks to treat saved prompt copies as part of the marked state"
1225        );
1226        assert!(
1227            bash_hooks.contains("TASKERS_OSC133_PROMPT_MARKED=1"),
1228            "expected bash hooks to keep the marked state synchronized with the prompt save guards"
1229        );
1230    }
1231
1232    #[test]
1233    fn shell_hooks_invalidate_metadata_cache_after_agent_exit() {
1234        let bash_hooks = include_str!(concat!(
1235            env!("CARGO_MANIFEST_DIR"),
1236            "/assets/shell/taskers-hooks.bash"
1237        ));
1238        let zsh_hooks = include_str!(concat!(
1239            env!("CARGO_MANIFEST_DIR"),
1240            "/assets/shell/taskers-hooks.zsh"
1241        ));
1242        let fish_hooks = include_str!(concat!(
1243            env!("CARGO_MANIFEST_DIR"),
1244            "/assets/shell/taskers-hooks.fish"
1245        ));
1246
1247        for asset in [bash_hooks, zsh_hooks, fish_hooks] {
1248            assert!(
1249                asset.contains("TASKERS_LAST_META_AGENT"),
1250                "expected hook asset to invalidate cached agent metadata after exit"
1251            );
1252            assert!(
1253                asset.contains("TASKERS_LAST_META_AGENT_ACTIVE"),
1254                "expected hook asset to invalidate cached agent-active metadata after exit"
1255            );
1256        }
1257    }
1258
1259    #[test]
1260    fn agent_proxy_owns_explicit_surface_agent_lifecycle_commands() {
1261        let bash_hooks = include_str!(concat!(
1262            env!("CARGO_MANIFEST_DIR"),
1263            "/assets/shell/taskers-hooks.bash"
1264        ));
1265        let zsh_hooks = include_str!(concat!(
1266            env!("CARGO_MANIFEST_DIR"),
1267            "/assets/shell/taskers-hooks.zsh"
1268        ));
1269        let fish_hooks = include_str!(concat!(
1270            env!("CARGO_MANIFEST_DIR"),
1271            "/assets/shell/taskers-hooks.fish"
1272        ));
1273        let agent_proxy = include_str!(concat!(
1274            env!("CARGO_MANIFEST_DIR"),
1275            "/assets/shell/taskers-agent-proxy.sh"
1276        ));
1277
1278        for asset in [bash_hooks, zsh_hooks, fish_hooks] {
1279            assert!(
1280                !asset.contains("surface agent-start"),
1281                "expected hook asset to leave explicit lifecycle start to the proxy"
1282            );
1283            assert!(
1284                !asset.contains("surface agent-stop"),
1285                "expected hook asset to leave explicit lifecycle stop to the proxy"
1286            );
1287        }
1288        assert!(
1289            agent_proxy.contains("surface agent-start"),
1290            "expected proxy asset to emit explicit surface agent start commands"
1291        );
1292        assert!(
1293            agent_proxy.contains("surface agent-stop"),
1294            "expected proxy asset to emit explicit surface agent stop commands"
1295        );
1296    }
1297
1298    #[test]
1299    fn embedded_zsh_codex_command_emits_surface_lifecycle_via_proxy() {
1300        let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1301        let runtime_root = unique_temp_dir("taskers-runtime-proxy-clean");
1302        install_runtime_assets(&runtime_root).expect("install runtime assets");
1303        super::install_agent_shims(&runtime_root).expect("install agent shims");
1304
1305        let home_dir = runtime_root.join("home");
1306        let real_bin_dir = runtime_root.join("real-bin");
1307        fs::create_dir_all(&home_dir).expect("home dir");
1308        fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1309
1310        let taskersctl_path = runtime_root.join("taskersctl");
1311        let args_log = runtime_root.join("codex-args.log");
1312        write_executable(
1313            &taskersctl_path,
1314            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1315        );
1316        write_executable(
1317            &real_bin_dir.join("codex"),
1318            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$FAKE_CODEX_ARGS_LOG\"\nnotify_script=\nprev=\nfor arg in \"$@\"; do\n  if [ \"$prev\" = \"-c\" ]; then\n    notify_script=$(printf '%s' \"$arg\" | sed -n 's/^notify=\\[\"bash\",\"\\([^\"]*\\)\"\\]$/\\1/p')\n    prev=\n    continue\n  fi\n  prev=$arg\ndone\nif [ -n \"$notify_script\" ]; then\n  \"$notify_script\" '{\"last-assistant-message\":\"Turn complete\"}'\nfi\nprintf 'fake codex\\n'\nexit 0\n",
1319        );
1320
1321        let original_home = std::env::var_os("HOME");
1322        let original_path = std::env::var_os("PATH");
1323        let original_zdotdir = std::env::var_os("ZDOTDIR");
1324        let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1325        let test_log = runtime_root.join("taskersctl.log");
1326        unsafe {
1327            std::env::set_var("HOME", &home_dir);
1328            std::env::remove_var("ZDOTDIR");
1329            std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1330            std::env::remove_var("TASKERS_SHELL_PROFILE");
1331            std::env::set_var(
1332                "PATH",
1333                format!(
1334                    "{}:{}",
1335                    real_bin_dir.display(),
1336                    original_path
1337                        .as_deref()
1338                        .map(|value| value.to_string_lossy().into_owned())
1339                        .unwrap_or_default()
1340                ),
1341            );
1342        }
1343
1344        let integration = ShellIntegration {
1345            root: runtime_root.clone(),
1346            wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1347            real_shell: zsh_path,
1348        };
1349        let mut launch = integration.launch_spec();
1350        launch.args.push("-c".into());
1351        launch.args.push("codex".into());
1352        launch.env.insert(
1353            "TASKERS_CTL_PATH".into(),
1354            taskersctl_path.display().to_string(),
1355        );
1356        launch
1357            .env
1358            .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1359        launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1360        launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1361        launch
1362            .env
1363            .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1364        launch
1365            .env
1366            .insert("FAKE_CODEX_ARGS_LOG".into(), args_log.display().to_string());
1367
1368        let mut spec = CommandSpec::new(launch.program.display().to_string());
1369        spec.args = launch.args;
1370        spec.env = launch.env;
1371
1372        let spawned = PtySession::spawn(&spec).expect("spawn shell");
1373        let mut reader = spawned.reader;
1374        let mut buffer = [0u8; 1024];
1375        let mut output = String::new();
1376        loop {
1377            let bytes_read = reader.read_into(&mut buffer).unwrap_or(0);
1378            if bytes_read == 0 {
1379                break;
1380            }
1381            output.push_str(&String::from_utf8_lossy(&buffer[..bytes_read]));
1382        }
1383
1384        let log = fs::read_to_string(&test_log)
1385            .unwrap_or_else(|error| panic!("read lifecycle log failed: {error}; output={output}"));
1386        let codex_args = fs::read_to_string(&args_log)
1387            .unwrap_or_else(|error| panic!("read codex args log failed: {error}; output={output}"));
1388        assert!(
1389            codex_args.contains("-c\n") || codex_args.contains("-c "),
1390            "expected codex wrapper to inject config override, got: {codex_args}"
1391        );
1392        assert!(
1393            codex_args.contains("notify=[\"bash\",\""),
1394            "expected codex wrapper to inject notify helper override, got: {codex_args}"
1395        );
1396        assert!(
1397            log.contains("surface agent-start --workspace ws --pane pn --surface sf --agent codex"),
1398            "expected start lifecycle in log, got: {log}"
1399        );
1400        assert!(
1401            log.contains("agent-hook stop --workspace ws --pane pn --surface sf --agent codex --title Codex --message Turn complete"),
1402            "expected codex notify helper to emit stop hook, got: {log}"
1403        );
1404        assert!(
1405            log.contains(
1406                "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 0"
1407            ),
1408            "expected stop lifecycle in log, got: {log}"
1409        );
1410
1411        restore_env_var("HOME", original_home);
1412        restore_env_var("PATH", original_path);
1413        restore_env_var("ZDOTDIR", original_zdotdir);
1414        fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1415    }
1416
1417    #[test]
1418    fn embedded_zsh_claude_command_injects_taskers_hooks_and_process_lifecycle() {
1419        let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1420        let runtime_root = unique_temp_dir("taskers-runtime-proxy-claude");
1421        install_runtime_assets(&runtime_root).expect("install runtime assets");
1422        super::install_agent_shims(&runtime_root).expect("install agent shims");
1423
1424        let home_dir = runtime_root.join("home");
1425        let real_bin_dir = runtime_root.join("real-bin");
1426        fs::create_dir_all(&home_dir).expect("home dir");
1427        fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1428
1429        let taskersctl_path = runtime_root.join("taskersctl");
1430        let args_log = runtime_root.join("claude-args.log");
1431        write_executable(
1432            &taskersctl_path,
1433            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1434        );
1435        write_executable(
1436            &real_bin_dir.join("claude"),
1437            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$FAKE_CLAUDE_ARGS_LOG\"\nexit 0\n",
1438        );
1439
1440        let original_home = std::env::var_os("HOME");
1441        let original_path = std::env::var_os("PATH");
1442        let original_zdotdir = std::env::var_os("ZDOTDIR");
1443        let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1444        let test_log = runtime_root.join("taskersctl.log");
1445        unsafe {
1446            std::env::set_var("HOME", &home_dir);
1447            std::env::remove_var("ZDOTDIR");
1448            std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1449            std::env::remove_var("TASKERS_SHELL_PROFILE");
1450            std::env::set_var(
1451                "PATH",
1452                format!(
1453                    "{}:{}",
1454                    real_bin_dir.display(),
1455                    original_path
1456                        .as_deref()
1457                        .map(|value| value.to_string_lossy().into_owned())
1458                        .unwrap_or_default()
1459                ),
1460            );
1461        }
1462
1463        let integration = ShellIntegration {
1464            root: runtime_root.clone(),
1465            wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1466            real_shell: zsh_path,
1467        };
1468        let mut launch = integration.launch_spec();
1469        launch.args.push("-c".into());
1470        launch.args.push("claude --help".into());
1471        launch.env.insert(
1472            "TASKERS_CTL_PATH".into(),
1473            taskersctl_path.display().to_string(),
1474        );
1475        launch
1476            .env
1477            .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1478        launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1479        launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1480        launch
1481            .env
1482            .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1483        launch.env.insert(
1484            "FAKE_CLAUDE_ARGS_LOG".into(),
1485            args_log.display().to_string(),
1486        );
1487
1488        let mut spec = CommandSpec::new(launch.program.display().to_string());
1489        spec.args = launch.args;
1490        spec.env = launch.env;
1491
1492        let spawned = PtySession::spawn(&spec).expect("spawn shell");
1493        let mut reader = spawned.reader;
1494        let mut buffer = [0u8; 1024];
1495        let mut output = String::new();
1496        loop {
1497            let bytes_read = reader.read_into(&mut buffer).unwrap_or(0);
1498            if bytes_read == 0 {
1499                break;
1500            }
1501            output.push_str(&String::from_utf8_lossy(&buffer[..bytes_read]));
1502        }
1503
1504        let log = fs::read_to_string(&test_log)
1505            .unwrap_or_else(|error| panic!("read lifecycle log failed: {error}; output={output}"));
1506        let claude_args = fs::read_to_string(&args_log).unwrap_or_else(|error| {
1507            panic!("read claude args log failed: {error}; output={output}")
1508        });
1509        let hook_path = runtime_root.join("taskers-claude-hook.sh");
1510        assert!(
1511            claude_args.contains("--settings"),
1512            "expected claude wrapper to inject hook settings, got: {claude_args}"
1513        );
1514        assert!(
1515            claude_args.contains(&hook_path.display().to_string())
1516                && claude_args.contains("user-prompt-submit"),
1517            "expected claude wrapper to inject prompt-submit hook path, got: {claude_args}"
1518        );
1519        assert!(
1520            claude_args.contains(&hook_path.display().to_string()) && claude_args.contains("stop"),
1521            "expected claude wrapper to inject stop hook path, got: {claude_args}"
1522        );
1523        assert!(
1524            log.contains(
1525                "surface agent-start --workspace ws --pane pn --surface sf --agent claude"
1526            ),
1527            "expected start lifecycle in log, got: {log}"
1528        );
1529        assert!(
1530            log.contains(
1531                "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 0"
1532            ),
1533            "expected stop lifecycle in log, got: {log}"
1534        );
1535
1536        restore_env_var("HOME", original_home);
1537        restore_env_var("PATH", original_path);
1538        restore_env_var("ZDOTDIR", original_zdotdir);
1539        fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1540    }
1541
1542    #[test]
1543    fn claude_code_shim_preserves_binary_lookup_and_quotes_hook_paths() {
1544        let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1545        let runtime_root = unique_temp_dir("taskers runtime claude code");
1546        install_runtime_assets(&runtime_root).expect("install runtime assets");
1547        super::install_agent_shims(&runtime_root).expect("install agent shims");
1548
1549        let real_bin_dir = runtime_root.join("real-bin");
1550        fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1551
1552        let capture_path = runtime_root.join("claude-code-capture.log");
1553        write_executable(
1554            &real_bin_dir.join("claude-code"),
1555            "#!/bin/sh\nprintf 'target=%s\\n' \"${TASKERS_AGENT_PROXY_TARGET:-}\" >> \"$FAKE_CLAUDE_CAPTURE\"\nprintf '%s\\n' \"$@\" >> \"$FAKE_CLAUDE_CAPTURE\"\nexit 0\n",
1556        );
1557
1558        let original_path = std::env::var_os("PATH");
1559        let shim_path = runtime_root.join("bin").join("claude-code");
1560        let output = Command::new(&shim_path)
1561            .env(
1562                "PATH",
1563                format!(
1564                    "{}:{}",
1565                    real_bin_dir.display(),
1566                    original_path
1567                        .as_deref()
1568                        .map(|value| value.to_string_lossy().into_owned())
1569                        .unwrap_or_default()
1570                ),
1571            )
1572            .env("FAKE_CLAUDE_CAPTURE", &capture_path)
1573            .arg("--help")
1574            .output()
1575            .expect("run claude-code shim");
1576
1577        assert!(
1578            output.status.success(),
1579            "expected claude-code shim to succeed, stdout={}, stderr={}",
1580            String::from_utf8_lossy(&output.stdout),
1581            String::from_utf8_lossy(&output.stderr)
1582        );
1583
1584        let capture = fs::read_to_string(&capture_path).expect("read capture log");
1585        let hook_path = runtime_root.join("taskers-claude-hook.sh");
1586        assert!(
1587            capture.contains("target=claude-code"),
1588            "expected shim to preserve the invoked claude-code lookup target, got: {capture}"
1589        );
1590        assert!(
1591            capture.contains("--settings"),
1592            "expected claude-code shim to forward hook settings, got: {capture}"
1593        );
1594        assert!(
1595            capture.contains(&format!("'{}' user-prompt-submit", hook_path.display())),
1596            "expected claude-code hook path to be single-quoted inside settings, got: {capture}"
1597        );
1598
1599        restore_env_var("PATH", original_path);
1600        fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1601    }
1602
1603    #[test]
1604    fn embedded_zsh_ctrl_c_reports_interrupted_surface_stop_via_proxy() {
1605        let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner());
1606        let runtime_root = unique_temp_dir("taskers-runtime-proxy-interrupt");
1607        install_runtime_assets(&runtime_root).expect("install runtime assets");
1608        super::install_agent_shims(&runtime_root).expect("install agent shims");
1609
1610        let home_dir = runtime_root.join("home");
1611        let real_bin_dir = runtime_root.join("real-bin");
1612        fs::create_dir_all(&home_dir).expect("home dir");
1613        fs::create_dir_all(&real_bin_dir).expect("real bin dir");
1614
1615        let taskersctl_path = runtime_root.join("taskersctl");
1616        write_executable(
1617            &taskersctl_path,
1618            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TASKERS_TEST_LOG\"\n",
1619        );
1620        write_executable(
1621            &real_bin_dir.join("codex"),
1622            "#!/bin/sh\ntrap 'exit 130' INT\nwhile :; do sleep 1; done\n",
1623        );
1624
1625        let original_home = std::env::var_os("HOME");
1626        let original_path = std::env::var_os("PATH");
1627        let original_zdotdir = std::env::var_os("ZDOTDIR");
1628        let zsh_path = resolve_shell_override("zsh").expect("resolve zsh");
1629        let test_log = runtime_root.join("taskersctl.log");
1630        unsafe {
1631            std::env::set_var("HOME", &home_dir);
1632            std::env::remove_var("ZDOTDIR");
1633            std::env::remove_var("TASKERS_DISABLE_SHELL_INTEGRATION");
1634            std::env::remove_var("TASKERS_SHELL_PROFILE");
1635            std::env::set_var(
1636                "PATH",
1637                format!(
1638                    "{}:{}",
1639                    real_bin_dir.display(),
1640                    original_path
1641                        .as_deref()
1642                        .map(|value| value.to_string_lossy().into_owned())
1643                        .unwrap_or_default()
1644                ),
1645            );
1646        }
1647
1648        let integration = ShellIntegration {
1649            root: runtime_root.clone(),
1650            wrapper_path: runtime_root.join("taskers-shell-wrapper.sh"),
1651            real_shell: zsh_path,
1652        };
1653        let mut launch = integration.launch_spec();
1654        launch.args.push("-c".into());
1655        launch.args.push("codex".into());
1656        launch.env.insert(
1657            "TASKERS_CTL_PATH".into(),
1658            taskersctl_path.display().to_string(),
1659        );
1660        launch
1661            .env
1662            .insert("TASKERS_WORKSPACE_ID".into(), "ws".into());
1663        launch.env.insert("TASKERS_PANE_ID".into(), "pn".into());
1664        launch.env.insert("TASKERS_SURFACE_ID".into(), "sf".into());
1665        launch
1666            .env
1667            .insert("TASKERS_TEST_LOG".into(), test_log.display().to_string());
1668
1669        let mut spec = CommandSpec::new(launch.program.display().to_string());
1670        spec.args = launch.args;
1671        spec.env = launch.env;
1672
1673        let mut spawned = PtySession::spawn(&spec).expect("spawn shell");
1674        std::thread::sleep(Duration::from_millis(250));
1675        spawned
1676            .session
1677            .write_all(b"\x03")
1678            .expect("send ctrl-c to shell");
1679
1680        let mut reader = spawned.reader;
1681        let mut buffer = [0u8; 1024];
1682        while reader.read_into(&mut buffer).unwrap_or(0) > 0 {}
1683
1684        let log = fs::read_to_string(&test_log).expect("read lifecycle log");
1685        assert!(
1686            log.contains("surface agent-start --workspace ws --pane pn --surface sf --agent codex"),
1687            "expected start lifecycle in log, got: {log}"
1688        );
1689        assert!(
1690            log.contains(
1691                "surface agent-stop --workspace ws --pane pn --surface sf --exit-status 130"
1692            ),
1693            "expected interrupted stop lifecycle in log, got: {log}"
1694        );
1695
1696        restore_env_var("HOME", original_home);
1697        restore_env_var("PATH", original_path);
1698        restore_env_var("ZDOTDIR", original_zdotdir);
1699        fs::remove_dir_all(&runtime_root).expect("cleanup runtime root");
1700    }
1701
1702    fn unique_temp_dir(prefix: &str) -> PathBuf {
1703        let unique = SystemTime::now()
1704            .duration_since(SystemTime::UNIX_EPOCH)
1705            .expect("time")
1706            .as_nanos();
1707        std::env::temp_dir().join(format!("{prefix}-{unique}"))
1708    }
1709
1710    fn restore_env_var(key: &str, value: Option<std::ffi::OsString>) {
1711        unsafe {
1712            match value {
1713                Some(value) => std::env::set_var(key, value),
1714                None => std::env::remove_var(key),
1715            }
1716        }
1717    }
1718
1719    fn write_executable(path: &PathBuf, content: &str) {
1720        fs::write(path, content).expect("write script");
1721        #[cfg(unix)]
1722        {
1723            let mut permissions = fs::metadata(path).expect("metadata").permissions();
1724            permissions.set_mode(0o755);
1725            fs::set_permissions(path, permissions).expect("chmod script");
1726        }
1727    }
1728}