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
10 changes: 10 additions & 0 deletions .tickets/_docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`"<title>\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
Expand Down
84 changes: 80 additions & 4 deletions src-tauri/src/chat/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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))?;
}
Expand Down Expand Up @@ -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");
Expand Down
Loading