Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .tickets/_docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<unset>`**:
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
Expand Down
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cmd>`; that
workaround covered only those. A script agent's configured `env`,
`TRIGGER_PROMPT` and `WORKING_DIR` all reached the pane as `<unset>` 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.
Expand Down
75 changes: 69 additions & 6 deletions src-tauri/src/chat/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))?;
Expand Down Expand Up @@ -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 <cmd>`.
///
/// `-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<String, String>) -> Vec<String> {
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
Expand Down Expand Up @@ -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))?;
Expand Down Expand Up @@ -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`
Expand Down
Loading