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 `<<<KAITENCODE_DONE:{task_id}>>>` (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 +/// `"<title>\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::<Vec<_>>() + .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 `"<task>\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 `"<task>\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");