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