diff --git a/.tickets/_docs/ROADMAP.md b/.tickets/_docs/ROADMAP.md
index 30ff1eed..085c4d84 100644
--- a/.tickets/_docs/ROADMAP.md
+++ b/.tickets/_docs/ROADMAP.md
@@ -35,6 +35,16 @@ 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.
+- 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
+ the Enter was fired with no settle, so even a flattened payload had it dropped
+ mid-ingest. Since the default prompt is multiline, interactive triggers had
+ never submitted their own prompt — the pane sat with the text visible and
+ unsent until the 2-hour timeout. Verified end to end: agent read `.agent.md`,
+ fixed the bug, wrote its proof file, and emitted the done sentinel, which was
+ detected and persisted as the advisory (interactive is deliberately advisory,
+ not auto-advancing — the session stays alive for the human).
- Managed (bubbles) trigger mode made actually usable (2026-08-21), all found by
running claude through a real column: it never passed
`--dangerously-skip-permissions`, so every file edit was denied and the agent
diff --git a/CLAUDE.md b/CLAUDE.md
index 9cf5db63..836dfcb8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -143,6 +143,15 @@ The legacy DB tokens `'terminal'` and `'managed'` are both headless variants (te
**Interactive mode** (gated by `KAITENCODE_INTERACTIVE_MODE_ENABLED=1` until the dev flag is promoted):
- Spawns the real CLI TUI (no `-p`/`exec`) inside the tmux session via `chat::bridge::spawn_interactive_cli` — argv-based dispatch on `InteractiveCli::Claude` vs `InteractiveCli::Codex`
- Waits for the pane to be usable before injecting the initial prompt: a CLI-specific ready glyph as a fast path (Claude: `╭`/`╰` box-drawing chars; Codex: `codex` banner substring), falling back to **pane quiescence** (content unchanged for ~750ms). The budget is 60s and exhausting it is **not** fatal — `ReadinessTracker`/`ReadinessVerdict` return `GiveUp`, and the caller injects anyway and leaves the session alive so the pane stays inspectable. (It used to be a fixed 5s budget that killed the session on miss, which destroyed the evidence for every slow cold start.)
+- **The injected prompt is flattened to one line** (`bridge::flatten_for_injection`)
+ and Enter is sent after a short settle (`INTERACTIVE_SUBMIT_SETTLE`). Both are
+ load-bearing: a multiline `send-keys -l` payload leaves the TUI in multi-line
+ input so the following Enter adds a line instead of submitting, and an Enter
+ sent immediately after the text is dropped while the composer is still
+ ingesting. Either way the prompt sits visible-but-unsent and the trigger waits
+ out its 2-hour timeout. Since the default prompt is
+ `"
\n\nSee .task.md for full spec."`, interactive triggers had never
+ actually submitted their own prompt.
- Carries the done-sentinel when the column's exit criteria is `agent_complete` or `manual_approval` — but the mechanism differs per CLI. **Claude:** `--append-system-prompt`. **Codex has no such flag** (verified against codex-cli 0.145.0), so its sentinel is folded into the injected prompt by `append_sentinel_to_prompt`, which must stay newline-free because the prompt goes in as one `send-keys -l` payload.
- Resume is modelled by `InteractiveResume` (`None` / `Id` / `Last`). Codex's `resume` is a **subcommand**, not a flag, and is cwd-filtered — so `resume --last` in a task worktree continues that task's session, and `agent_restart` uses it. Claude needs an explicit session id we don't capture from the TUI, so claude restarts fresh.
- A 2s-cadence watcher (`watch_interactive_sentinel`) scans `tmux capture-pane` for `<<>>` (after ANSI strip, line-anchored) and runs `mark_complete` on hit
diff --git a/src-tauri/src/chat/bridge.rs b/src-tauri/src/chat/bridge.rs
index f45ecc3f..b82d2977 100644
--- a/src-tauri/src/chat/bridge.rs
+++ b/src-tauri/src/chat/bridge.rs
@@ -2610,14 +2610,49 @@ pub(crate) fn interactive_sentinel_system_prompt(task_id: &str) -> String {
)
}
+/// 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
+/// newlines do **not** submit each line as you might expect — they put the TUI
+/// into multi-line input, and the single `Enter` that follows adds another
+/// line instead of sending. The result is the whole prompt sitting in the
+/// composer, unsubmitted, while the trigger waits for a completion that can
+/// never arrive.
+///
+/// This was not hypothetical: the default trigger prompt is
+/// `"\n\nSee .task.md for full spec."`, so interactive trigger mode had
+/// never actually submitted its own prompt. Observed directly in the pane.
+///
+/// Nothing is lost by flattening — the detail lives in `.task.md` and
+/// `.agent.md`, and the prompt is only a pointer to them.
+/// Pause between injecting the prompt text and pressing Enter.
+///
+/// Generous on purpose: the cost of waiting is a fraction of a second on a run
+/// that lasts minutes, while the cost of being too quick is a trigger that
+/// hangs until its 2-hour timeout with the prompt sitting unsent in the
+/// composer.
+const INTERACTIVE_SUBMIT_SETTLE: Duration = Duration::from_millis(600);
+
+pub(crate) fn flatten_for_injection(prompt: &str) -> String {
+ prompt
+ .lines()
+ .map(str::trim)
+ .filter(|line| !line.is_empty())
+ .collect::>()
+ .join(" ")
+}
+
/// Fold the sentinel instruction into the initial prompt, for CLIs that have
/// no system-prompt flag to carry it (codex — verified against 0.145.0, which
/// has no `--append-system-prompt`).
///
/// MUST stay newline-free. The initial prompt is delivered as a single
-/// `tmux send-keys -l` payload, and a literal newline inside it submits the
-/// line early in a TUI — so the instruction is appended on the same line
-/// rather than as the `"\n\nWhen done…"` block shape you might expect.
+/// `tmux send-keys -l` payload, and a literal newline inside it leaves the TUI
+/// in multi-line input — the following `Enter` then adds a line instead of
+/// submitting, and the prompt sits in the composer forever. So the instruction
+/// is appended on the same line rather than as the `"\n\nWhen done…"`
+/// block shape you might expect. See [`flatten_for_injection`], which enforces
+/// the same rule for the prompt this is appended to.
pub(crate) fn append_sentinel_to_prompt(prompt: &str, task_id: &str) -> String {
let instruction = interactive_sentinel_system_prompt(task_id);
let base = prompt.trim_end();
@@ -2894,7 +2929,7 @@ pub(crate) fn spawn_interactive_cli(
let cli_exec = resolve_cli_exec(cli_command);
// Claude carries the sentinel as a system prompt (isolated from the user's
// text). Codex has no such flag, so its sentinel rides the prompt instead.
- let mut prompt_to_inject = initial_prompt.to_string();
+ let mut prompt_to_inject = flatten_for_injection(initial_prompt);
let argv = match cli {
InteractiveCli::Claude => {
let resume_id = match resume {
@@ -3006,6 +3041,15 @@ pub(crate) fn spawn_interactive_cli(
if !prompt_to_inject.is_empty() {
run_tmux(&["send-keys", "-t", &session, "-l", &prompt_to_inject])
.map_err(|e| format!("send-keys (literal) for interactive prompt failed: {}", e))?;
+
+ // Let the composer ingest the payload before submitting. These TUIs
+ // process pasted text asynchronously, and an `Enter` that lands while
+ // the buffer is still being filled is simply dropped: the prompt then
+ // sits in the composer, visible and unsent, while the trigger waits
+ // for a completion that can never come. Observed directly — the same
+ // pane submitted instantly when Enter was sent by hand a moment later.
+ std::thread::sleep(INTERACTIVE_SUBMIT_SETTLE);
+
run_tmux(&["send-keys", "-t", &session, "Enter"])
.map_err(|e| format!("send-keys Enter for interactive prompt failed: {}", e))?;
}
@@ -4183,6 +4227,38 @@ mod tests {
assert_eq!(argv, vec!["codex"]);
}
+ #[test]
+ fn flatten_for_injection_collapses_the_default_trigger_prompt() {
+ // The default prompt is multiline, and a multiline `send-keys -l`
+ // payload leaves the TUI in multi-line input: the following Enter adds
+ // a line instead of submitting, so the prompt never gets sent. This is
+ // the exact shape that had been silently stalling interactive triggers.
+ let out = flatten_for_injection("My Task\n\nSee .task.md for full spec.");
+ assert_eq!(out, "My Task See .task.md for full spec.");
+ assert!(!out.contains('\n'));
+ }
+
+ #[test]
+ fn flatten_for_injection_handles_agent_and_edge_shapes() {
+ // With an agent attached the prompt gains a third line.
+ let withagent = flatten_for_injection(
+ "My Task\n\nSee .task.md for full spec.\n\nFollow the instructions in .agent.md.",
+ );
+ assert_eq!(
+ withagent,
+ "My Task See .task.md for full spec. Follow the instructions in .agent.md."
+ );
+
+ // Already single-line prompts pass through untouched.
+ assert_eq!(flatten_for_injection("just one line"), "just one line");
+ // Blank and whitespace-only inputs collapse to empty, not to spaces —
+ // the caller skips injection entirely when the payload is empty.
+ assert_eq!(flatten_for_injection(""), "");
+ assert_eq!(flatten_for_injection("\n\n \n"), "");
+ // Indentation from a templated prompt doesn't leak in as double spaces.
+ assert_eq!(flatten_for_injection("a\n b\n\n c "), "a b c");
+ }
+
#[test]
fn test_append_sentinel_to_prompt_embeds_marker_without_newlines() {
let out = append_sentinel_to_prompt("Do the thing.", "task-zzz");