From 683a059decbae5aba58851cbc7a51908f49ebbac Mon Sep 17 00:00:00 2001 From: Drew Bradford <16455249+DrewBradfordXYZ@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:32:33 -0400 Subject: [PATCH] Claude Code no longer dies at launch on macOS with "EINVAL: invalid argument, kqueue" #130 gave the login shell /dev/null as stdin while the rc files run, then reopened /dev/tty before the agent command. macOS kqueue refuses /dev/tty, and Claude Code (Bun) watches stdin with kqueue, so every Claude agent exited 1 on startup. The stdin shim now records the PTY's own device path in NEBULA_PANE_TTY before the rc files run, and the launch line reopens stdin from that path (then unsets it). rc files still see a non-TTY stdin, so the Iris autostart fix from #130 holds; its e2e test now also asserts the agent's stdin is not /dev/tty. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/nebula-core/src/env.rs | 7 +++++ crates/nebula-daemon/src/registry.rs | 45 ++++++++++++++++++---------- crates/nebula/tests/e2e_pty.rs | 11 ++++++- 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/crates/nebula-core/src/env.rs b/crates/nebula-core/src/env.rs index 31e071ca..70c9a835 100644 --- a/crates/nebula-core/src/env.rs +++ b/crates/nebula-core/src/env.rs @@ -65,6 +65,13 @@ pub const CLAUDE_CONFIG_DIR: &str = "CLAUDE_CONFIG_DIR"; /// its hooks. pub const CODEX_HOME: &str = "CODEX_HOME"; +/// The pane's PTY device (`/dev/ttys003`), named by the login-shell stdin +/// shim before the rc files run on `/dev/null` and reopened as stdin just +/// before the command — `/dev/tty` would do on Linux, but macOS kqueue +/// refuses it (`EINVAL`), which Bun's (Claude Code's) stdin watcher dies +/// on. Unset again before the command runs, so no child inherits it. +pub const PANE_TTY: &str = "NEBULA_PANE_TTY"; + /// Env vars that identify an agent session to the daemon. They are set on /// every agent PTY and must never leak into plain terminals. pub const AGENT_SESSION_VARS: &[&str] = &[AGENT_ID, API_URL, API_TOKEN]; diff --git a/crates/nebula-daemon/src/registry.rs b/crates/nebula-daemon/src/registry.rs index 0a236698..b50d0c17 100644 --- a/crates/nebula-daemon/src/registry.rs +++ b/crates/nebula-daemon/src/registry.rs @@ -59,16 +59,27 @@ struct ResumeWatch { /// `$SHELL -l -i -c `: a login *and* interactive shell, so zsh sources /// ~/.zprofile and ~/.zshrc both and the child sees the PATH the user's /// terminal has. CLI probes use it directly, and PTY launches wrap it through -/// [`LOGIN_SHELL_STDIN_SHIM`] below so rc-file startup sees stdin as non-TTY +/// [`login_shell_stdin_shim`] below so rc-file startup sees stdin as non-TTY /// until nebula's command is about to run. const LOGIN_SHELL_ARGS: [&str; 3] = ["-l", "-i", "-c"]; /// Tiny POSIX wrapper for PTY launches: keep stdout/stderr on the PTY, but /// give the login shell `/dev/null` as stdin while it sources rc files. /// Shell startup tools that auto-`exec` only when stdin is a TTY (Iris-style /// autocomplete launchers) then leave the `-c` payload alone. The payload -/// reopens `/dev/tty` before starting the agent, so the CLI itself remains -/// fully interactive. -const LOGIN_SHELL_STDIN_SHIM: &str = "exec String { + let var = env::PANE_TTY; + format!("{var}=$(tty) || {var}=/dev/tty; export {var}; exec String { + let var = env::PANE_TTY; + format!("exec <\"${var}\"; unset {var}; ") +} /// Cap on one CLI probe. A heavy rc file costs ~1s; a hung one must not /// stall a create forever, so on timeout the CLI is assumed present and /// the spawn itself gets to report. @@ -815,7 +826,7 @@ fn resolve_harness_in( } /// Wrap `program args…` in a login + interactive shell (`$SHELL -l -i -c -/// 'exec (String, Vec /// TERMINAL's `.nebula.json` `run`, pipes and `&&` and all — behind the /// same prelude. fn login_shell_line(shell: &str, line: &str) -> (String, Vec) { - let mut cmdline = String::from("exec (String, Vec) { cmdline.push_str(env::PANE_COLORTERM); cmdline.push_str("; "); cmdline.push_str(line); - let mut args = vec!["-c".to_string(), LOGIN_SHELL_STDIN_SHIM.to_string()]; + let mut args = vec!["-c".to_string(), login_shell_stdin_shim()]; args.push("nebula-login-shell".to_string()); args.push(shell.to_string()); args.extend(LOGIN_SHELL_ARGS.iter().map(|s| s.to_string())); @@ -2028,24 +2040,27 @@ mod tests { args, vec![ "-c", - LOGIN_SHELL_STDIN_SHIM, + &login_shell_stdin_shim(), "nebula-login-shell", "/bin/zsh", "-l", "-i", "-c", - &format!("exec `. Nebula keeps the login shell interactive for rc /// files, but makes stdin non-TTY until the launch line itself reopens the /// PTY, so aliases/functions from rc still work and the agent gets a real -/// terminal. +/// terminal — by the PTY's own device path, not `/dev/tty`: macOS kqueue +/// refuses `/dev/tty`, and Claude Code (Bun) dies on its stdin watcher with +/// `EINVAL: invalid argument, kqueue` the moment it starts. #[tokio::test] async fn create_agent_survives_rc_autostart_that_execs_on_tty_stdin() { let env = TestEnv::new(); @@ -3370,6 +3372,7 @@ async fn create_agent_survives_rc_autostart_that_execs_on_tty_stdin() { "fi\n", "claude() {{\n", " if [ -t 0 ]; then tty=tty; else tty=notty; fi\n", + " tty > '{d}/claude-stdin'\n", " echo \"claude $tty $*\" > '{d}/claude-ran'\n", " sleep 600\n", "}}\n", @@ -3415,6 +3418,12 @@ async fn create_agent_survives_rc_autostart_that_execs_on_tty_stdin() { !dir.join("iris").exists(), "the rc autostart hook should not have taken over the PTY" ); + let stdin = std::fs::read_to_string(dir.join("claude-stdin")).unwrap(); + assert_ne!( + stdin.trim_end(), + "/dev/tty", + "stdin must be the PTY's own device, which kqueue can watch" + ); write_frame( &mut c,