From 896f6cd9e7866f7015e74908214e70681f9e9017 Mon Sep 17 00:00:00 2001 From: ANonABento Date: Sat, 22 Aug 2026 15:15:31 -0400 Subject: [PATCH] fix(tmux): session env never reached the pane, so script agents ran blind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running a script-runtime agent from a column for the first time. argv, cwd and exit-code advancement were already correct — the script got exactly `--preset high --out cut.mp4`, with no prose prompt appended, and a deliberately strict argument parser accepted it (`ARGC: 4`). But every environment variable arrived unset: PRESET: THREADS: TRIGGER_PROMPT: WORKING_DIR: Session environment was being applied with `Command::env` on the tmux *client* process. The tmux server is a pre-existing daemon, so `new-session` only asks it to create a session and the new shell inherits the **server's** environment, not the client's. Everything set that way was silently dropped. That is also why the `KAITENCODE_PARENT_*` attribution variables are additionally inlined on the command line as `KEY=val ` — that workaround covered exactly those and nothing else. Consequences: a script agent's configured `env` was inert while the dossier displayed it, and script agents received no prompt at all — neither in argv (correctly, it would break strict parsers) nor in `$TRIGGER_PROMPT`. They ran context-free. The claim in the previous commit that the prompt rides `$TRIGGER_PROMPT` was true of the intent and false of the behaviour. `tmux new-session -e KEY=VAL` sets it server-side and the pane created by `new-session` inherits it, including values containing spaces and `=`. Verified against tmux 3.4, which is what ships here. Applied to both session creation paths — the trigger one and the interactive one, which had the same bug. Same run afterwards: PRESET: fast THREADS: 8 TRIGGER_PROMPT: Render the cut… WORKING_DIR: …/worktrees/kaitencode-… Also confirms the resolver's script handling from #275 holds in practice: the column deliberately carried a stale `cli: claude` and `runtime_mode: managed`, and both were correctly ignored — the run resolved to `terminal` and executed the agent's own command. One test-artifact worth recording: deleting a worktree out from under a live tmux session leaves the pane's cwd on a removed inode, and the next command fails with `getcwd: cannot access parent directories`. That is the situation `column_is_terminal` already forces a fresh pane for; it was my reset sequence, not a product bug. --- .tickets/_docs/ROADMAP.md | 8 ++++ CLAUDE.md | 9 +++++ src-tauri/src/chat/bridge.rs | 75 +++++++++++++++++++++++++++++++++--- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/.tickets/_docs/ROADMAP.md b/.tickets/_docs/ROADMAP.md index 085c4d8..7018945 100644 --- a/.tickets/_docs/ROADMAP.md +++ b/.tickets/_docs/ROADMAP.md @@ -35,6 +35,14 @@ replaces the terminal. with a 60s budget and **never** hard-kills — exhausting it injects anyway and leaves the pane inspectable. `CLI_HEALTH_SPECS` now probes the interactive codex path so the same drift can't ship silently again. +- Script agents verified end to end + tmux session env fixed (2026-08-22). argv, + cwd and exit-code advancement were already correct (no prose in argv — strict + `ARGC` check passed), but **every environment variable arrived ``**: + session env was being set with `Command::env` on the tmux *client*, and the + server is a pre-existing daemon so the pane inherits the server's environment. + A script agent's configured `env` was silently inert and `TRIGGER_PROMPT` + never arrived, so script agents ran context-free. Now passed via + `tmux new-session -e`. - Interactive trigger mode: the prompt now actually gets submitted (2026-08-22). It was injected as a multiline `send-keys -l` payload, which leaves the TUI in multi-line input so the following Enter adds a line rather than sending; and diff --git a/CLAUDE.md b/CLAUDE.md index 836dfcb..f1de509 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,6 +178,15 @@ Each task gets a tmux session (`kaitencode_{task_id}`) with an embedded terminal - Sessions persist across app restarts — tmux keeps running, app rediscovers on startup - `ManagedBridge` forwards broadcast events to frontend (one bridge per task, auto-cancelled on remove) +**Session env must go through `tmux new-session -e KEY=VAL`**, never +`Command::env` on the tmux client. The tmux server is a pre-existing daemon, so +a new pane inherits the *server's* environment, not the client's — everything +set the client way was silently dropped. That is why the `KAITENCODE_PARENT_*` +attribution vars are *also* inlined on the command line as `KEY=val `; that +workaround covered only those. A script agent's configured `env`, +`TRIGGER_PROMPT` and `WORKING_DIR` all reached the pane as `` until this +was fixed (`bridge::tmux_env_args`, verified against tmux 3.4). + **Trigger integration:** `spawn_cli_trigger_task` uses `tmux send-keys -l` for command injection + `tmux wait-for` for completion detection. Exit code read from temp file in app data dir. No sentinel patterns, no shell ready detection — tmux handles session readiness. `.task.md` written to worktree before trigger fires (token optimization — agent reads file instead of getting full spec in prompt). **Completion detection:** `tmux wait-for {channel}` blocks until the injected command signals completion. 2-hour timeout prevents stuck tasks. Column guard prevents stale triggers from corrupting pipeline state if task moved during execution. diff --git a/src-tauri/src/chat/bridge.rs b/src-tauri/src/chat/bridge.rs index b82d297..7036d23 100644 --- a/src-tauri/src/chat/bridge.rs +++ b/src-tauri/src/chat/bridge.rs @@ -1202,11 +1202,17 @@ fn create_trigger_session( args.push(working_dir); } + // Session environment must go through `-e`, not `Command::env` — see + // `tmux_env_args`. Built after the positional flags so `-c` stays adjacent + // to its value. + let env_args = tmux_env_args(env_vars); + let mut args: Vec<&str> = args; + for a in &env_args { + args.push(a.as_str()); + } + let mut cmd = Command::new("tmux"); cmd.args(&args); - for (k, v) in env_vars { - cmd.env(k, v); - } let output = cmd .output() .map_err(|e| format!("Failed to spawn tmux new-session: {}", e))?; @@ -2610,6 +2616,31 @@ pub(crate) fn interactive_sentinel_system_prompt(task_id: &str) -> String { ) } +/// Render an env map as `tmux new-session -e KEY=VAL` arguments. +/// +/// Setting the variables on the `tmux` *client* process (`Command::env`) does +/// not reach the pane: the tmux server is a pre-existing daemon, so +/// `new-session` only asks it to create a session and the new shell inherits +/// the **server's** environment, not the client's. Everything set that way was +/// silently dropped — which is why the attribution variables are additionally +/// inlined on the command line as `KEY=val `. +/// +/// `-e` (tmux 3.2+, and 3.4 is what ships here) sets it on the session itself, +/// server-side, and the pane created by `new-session` inherits it — including +/// values containing spaces. Verified against tmux 3.4. +/// +/// Keys are sorted so the argv is deterministic. +fn tmux_env_args(env_vars: &HashMap) -> Vec { + let mut entries: Vec<(&String, &String)> = env_vars.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + let mut args = Vec::with_capacity(entries.len() * 2); + for (k, v) in entries { + args.push("-e".to_string()); + args.push(format!("{}={}", k, v)); + } + args +} + /// Collapse a prompt to a single line before injecting it into a TUI. /// /// The initial prompt goes in as one `tmux send-keys -l` payload. Embedded @@ -2974,15 +3005,15 @@ pub(crate) fn spawn_interactive_cli( tmux_args.push("-c".to_string()); tmux_args.push(working_dir.to_string()); } + // `-e` flags must come before the positional command argv, or tmux reads + // them as part of the command to run. + tmux_args.extend(tmux_env_args(env_vars)); for a in &argv { tmux_args.push(a.clone()); } let mut cmd = Command::new("tmux"); cmd.args(&tmux_args); - for (k, v) in env_vars { - cmd.env(k, v); - } let output = cmd .output() .map_err(|e| format!("Failed to spawn interactive tmux session: {}", e))?; @@ -4227,6 +4258,38 @@ mod tests { assert_eq!(argv, vec!["codex"]); } + #[test] + fn tmux_env_args_emits_sorted_e_flags() { + // `Command::env` never reached the pane — the tmux server is a + // pre-existing daemon, so the new shell inherits its environment, not + // the client's. A script agent's configured `env` was silently inert + // as a result, and so was TRIGGER_PROMPT. + let mut env = HashMap::new(); + env.insert("RENDER_THREADS".to_string(), "8".to_string()); + env.insert("FFMPEG_PRESET".to_string(), "fast".to_string()); + assert_eq!( + tmux_env_args(&env), + vec![ + "-e".to_string(), + "FFMPEG_PRESET=fast".to_string(), + "-e".to_string(), + "RENDER_THREADS=8".to_string(), + ] + ); + } + + #[test] + fn tmux_env_args_keeps_values_with_spaces_and_equals_intact() { + // TRIGGER_PROMPT is prose and can contain both. + let mut env = HashMap::new(); + env.insert("TRIGGER_PROMPT".to_string(), "fix add() a = b".to_string()); + assert_eq!( + tmux_env_args(&env), + vec!["-e".to_string(), "TRIGGER_PROMPT=fix add() a = b".to_string()] + ); + assert!(tmux_env_args(&HashMap::new()).is_empty()); + } + #[test] fn flatten_for_injection_collapses_the_default_trigger_prompt() { // The default prompt is multiline, and a multiline `send-keys -l`