From 28c4a9bf3c46939a3f2b0ba245b12a2ea31011b3 Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Sun, 9 Aug 2026 04:29:04 +0200 Subject: [PATCH 1/2] fix: wait for agent prompt readiness refs #2410 --- .../src/content/docs/agent-automation.mdx | 2 +- .../src/content/docs/cli-reference.mdx | 2 +- .../src/content/docs/ja/agent-automation.mdx | 2 +- .../src/content/docs/ja/cli-reference.mdx | 2 +- .../content/docs/zh-cn/agent-automation.mdx | 2 +- .../src/content/docs/zh-cn/cli-reference.mdx | 2 +- src/app/actions.rs | 139 +++++++++++++++++- src/app/api.rs | 28 ++-- src/cli/agent.rs | 28 ++-- src/detect/manifest/tests.rs | 34 +++++ src/detect/manifests/codex.toml | 17 ++- src/events.rs | 6 + src/pane.rs | 46 ++++-- src/server/headless.rs | 85 ++++++++++- src/terminal/state.rs | 95 ++++++++++-- tests/cli/agent_transport.rs | 43 ++++-- website/agent-detection/codex.toml | 17 ++- 17 files changed, 464 insertions(+), 86 deletions(-) diff --git a/docs/next/website/src/content/docs/agent-automation.mdx b/docs/next/website/src/content/docs/agent-automation.mdx index 07847ed133..807bebd8c1 100644 --- a/docs/next/website/src/content/docs/agent-automation.mdx +++ b/docs/next/website/src/content/docs/agent-automation.mdx @@ -43,7 +43,7 @@ An available shell pane is at its interactive shell prompt: the shell itself own `--kind` selects a supported agent and its canonical executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, and `maki`. Arguments after `--` are passed unchanged to that executable. -`agent start` returns only after Herdr detects the expected agent in the same terminal and marks it ready for interactive input. Startup waits for 30 seconds by default; `--timeout` must be greater than 3000 and no more than 300000 milliseconds. +Successful `agent start` returns only after Herdr detects the expected agent in the same terminal and marks it ready for interactive input. If detection reports `blocked` during startup, the command returns `agent_not_ready` immediately. The name remains available for `agent read` and `agent send-keys`, and becomes ready for prompts after detection reports `idle`. Startup waits for 30 seconds by default; `--timeout` must be greater than 3000 and no more than 300000 milliseconds. ```bash herdr agent start reviewer --kind codex --pane "$review_pane" -- -m gpt-5.4 diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 7ce6c314f9..ed4cd2445b 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -307,7 +307,7 @@ Agent targets are either a unique live agent name or the pane ID that currently `agent start` activates an existing available shell pane: the pane's interactive shell must own the foreground, with no foreground command, editor, or agent running. Topology must be created separately. Names are unique among live agents and must match `[a-z][a-z0-9_-]{0,31}`. The kind selects Herdr's canonical interactive executable, while arguments after `--` are passed to that executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, and `maki`. A name follows the current pane occupant and is cleared when that agent exits, is released, or is replaced. Temporary detection uncertainty does not clear it. -Start returns only after the expected agent owns the same terminal and is ready for interactive input. The default startup timeout is 30000 milliseconds; explicit values must be greater than 3000 and no more than 300000. +A successful start returns only after the expected agent owns the same terminal and is ready for interactive input. If detection reports `blocked` during startup, the command returns `agent_not_ready` immediately. The name remains available for `agent read` and `agent send-keys`, and becomes ready for prompts after detection reports `idle`. The default startup timeout is 30000 milliseconds; explicit values must be greater than 3000 and no more than 300000. `agent prompt` honors live bracketed-paste mode and submits text plus encoded Enter atomically, including while the agent is working. With `--wait`, a prompt sent from a non-working state must produce an observed lifecycle change within five seconds or Herdr returns `agent_prompt_stalled`; a caller timeout of five seconds or less keeps the normal `timeout` error. After activity is observed, it waits for the first requested settled status. It does not track individual turns. If the agent is already working, completion of that active turn may satisfy the wait. `--until` narrows the matching states and is rejected unless `--wait` is also present. Standalone `agent wait` returns immediately when the current status matches. Both default to `idle`, `done`, or `blocked`; use `--until unknown` explicitly when needed. diff --git a/docs/next/website/src/content/docs/ja/agent-automation.mdx b/docs/next/website/src/content/docs/ja/agent-automation.mdx index 666d039d13..74584574bb 100644 --- a/docs/next/website/src/content/docs/ja/agent-automation.mdx +++ b/docs/next/website/src/content/docs/ja/agent-automation.mdx @@ -43,7 +43,7 @@ review_pane=$(printf '%s\n' "$split" | jq -r '.result.pane.pane_id') `--kind` は対応済みエージェントとその標準実行ファイルを選びます。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`maki` です。`--` より後の引数は、その実行ファイルへそのまま渡されます。 -`agent start` は、同じターミナルで期待したエージェントを検出し、対話入力の準備ができたと Herdr が判断してから返ります。起動待機はデフォルトで 30 秒です。`--timeout` は 3000 より大きく 300000 以下のミリ秒で指定します。 +成功した `agent start` は、同じターミナルで期待したエージェントを検出し、対話入力の準備ができたと Herdr が判断してから返ります。起動中の検出状態が `blocked` の場合、コマンドは直ちに `agent_not_ready` を返します。名前は `agent read` と `agent send-keys` で引き続き使用でき、検出状態が `idle` になるとプロンプトを送信できるようになります。起動待機はデフォルトで 30 秒です。`--timeout` は 3000 より大きく 300000 以下のミリ秒で指定します。 ```bash herdr agent start reviewer --kind codex --pane "$review_pane" -- -m gpt-5.4 diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx index 3cc1e13b32..8664affe43 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -287,7 +287,7 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] `agent start` は既存の利用可能なシェルペインを起動対象にします。対話シェル自身がフォアグラウンドを所有し、フォアグラウンドのコマンド、エディタ、エージェントが動いていない必要があります。トポロジーは別に作成します。名前はライブエージェント間で一意で、`[a-z][a-z0-9_-]{0,31}` に一致する必要があります。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`maki` です。名前は現在のペイン占有者に属し、そのエージェントの終了、release、置換で消えます。一時的に検出できないだけでは消えません。 -start は、期待したエージェントが同じターミナルを所有し、対話入力の準備ができてから返ります。デフォルトの起動タイムアウトは 30000 ミリ秒で、明示する値は 3000 より大きく 300000 以下でなければなりません。 +成功した start は、期待したエージェントが同じターミナルを所有し、対話入力の準備ができてから返ります。起動中の検出状態が `blocked` の場合、コマンドは直ちに `agent_not_ready` を返します。名前は `agent read` と `agent send-keys` で引き続き使用でき、検出状態が `idle` になるとプロンプトを送信できるようになります。デフォルトの起動タイムアウトは 30000 ミリ秒で、明示する値は 3000 より大きく 300000 以下でなければなりません。 `agent prompt` は現在の bracketed paste モードを尊重し、working 中でもテキストとエンコード済み Enter をアトミックに送信します。`--wait` を使う場合、non-working 状態から送信したプロンプトは 5 秒以内に観測可能なライフサイクル変化を起こす必要があり、変化がなければ Herdr は `agent_prompt_stalled` を返します。呼び出し側のタイムアウトが 5 秒以下なら、通常の `timeout` エラーを維持します。活動を観測した後、要求された安定状態を待ちます。個々のターンは追跡しません。すでに working の場合、進行中ターンの完了が待機を満たすことがあります。`--until` は一致状態を絞り込み、`--wait` なしでは拒否されます。単独の `agent wait` は現在の状態が一致すれば即座に返ります。どちらもデフォルトは `idle`、`done`、`blocked` です。 diff --git a/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx b/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx index 4bd4f1f442..35b98b371c 100644 --- a/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agent-automation.mdx @@ -43,7 +43,7 @@ review_pane=$(printf '%s\n' "$split" | jq -r '.result.pane.pane_id') `--kind` 选择受支持的智能体及其标准可执行文件。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli` 和 `maki`。`--` 后的参数会原样传给该可执行文件。 -`agent start` 只有在 Herdr 于同一终端检测到预期智能体,并确认它可接受交互输入后才返回。默认等待启动 30 秒;`--timeout` 必须大于 3000 且不超过 300000 毫秒。 +成功的 `agent start` 只有在 Herdr 于同一终端检测到预期智能体,并确认它可接受交互输入后才返回。如果启动期间检测到 `blocked`,命令会立即返回 `agent_not_ready`。该名称仍可用于 `agent read` 和 `agent send-keys`,检测变为 `idle` 后即可用于发送提示。默认等待启动 30 秒;`--timeout` 必须大于 3000 且不超过 300000 毫秒。 ```bash herdr agent start reviewer --kind codex --pane "$review_pane" -- -m gpt-5.4 diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx index baebfcc85d..1cceb6dfc3 100644 --- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx @@ -287,7 +287,7 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] `agent start` 会在现有可用 shell 窗格中启动智能体:交互式 shell 必须占用前台,不能有正在前台运行的命令、编辑器或智能体。拓扑必须单独创建。名称在实时智能体中必须唯一,并匹配 `[a-z][a-z0-9_-]{0,31}`。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli` 和 `maki`。名称属于当前窗格占用者,在该智能体退出、release 或被替换时清除;短暂的检测不确定不会清除它。 -start 只有在预期智能体占用同一终端并可接受交互输入后才返回。默认启动超时是 30000 毫秒;显式值必须大于 3000 且不超过 300000。 +成功的 start 只有在预期智能体占用同一终端并可接受交互输入后才返回。如果启动期间检测到 `blocked`,命令会立即返回 `agent_not_ready`。该名称仍可用于 `agent read` 和 `agent send-keys`,检测变为 `idle` 后即可用于发送提示。默认启动超时是 30000 毫秒;显式值必须大于 3000 且不超过 300000。 `agent prompt` 遵循当前的 bracketed paste 模式,即使智能体处于 working 也会原子地提交文本和编码后的 Enter。使用 `--wait` 时,从非 working 状态发送的提示必须在五秒内产生可观察的生命周期变化,否则 Herdr 返回 `agent_prompt_stalled`;调用方超时不超过五秒时,仍返回普通的 `timeout` 错误。观察到活动后,它会等待请求的稳定状态。它不会跟踪单独的轮次。如果智能体已经处于 working,当前轮次的完成可能满足等待。`--until` 用于缩小匹配状态,不带 `--wait` 时会被拒绝。独立的 `agent wait` 在当前状态匹配时立即返回。两者默认匹配 `idle`、`done` 或 `blocked`;需要 `unknown` 时请明确使用 `--until unknown`。 diff --git a/src/app/actions.rs b/src/app/actions.rs index 20deab2af2..f1d2f05ab2 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -177,7 +177,10 @@ pub fn notification_toast_for_pane_state_update( suppress_active_tab_notifications: bool, update: &PaneStateUpdate, ) -> Option { - if suppress_active_tab_notifications || update.state == update.previous_state { + if update.suppress_completion + || suppress_active_tab_notifications + || update.state == update.previous_state + { return None; } @@ -249,6 +252,7 @@ pub struct PaneStateUpdate { pub agent_name_changed: bool, pub agent_released: bool, pub agent_release_status: Option, + pub suppress_completion: bool, } // --------------------------------------------------------------------------- @@ -1036,7 +1040,7 @@ impl AppState { .get_mut(&terminal_id)? .expire_agent_metadata_at(scheduled_deadline, now)?; let change = mutation.effective_state_change?; - let seen = self.apply_pane_state_change(ws_idx, pane_id, &change)?; + let seen = self.apply_pane_state_change(ws_idx, pane_id, &change, false)?; let update = PaneStateUpdate { pane_id, ws_idx, @@ -1053,6 +1057,7 @@ impl AppState { agent_name_changed: false, agent_released: false, agent_release_status: None, + suppress_completion: false, }; Some(update) }) @@ -2771,6 +2776,16 @@ impl AppState { } Vec::new() } + AppEvent::AgentProcessDetected { + pane_id, + agent, + observed_at, + } => self + .update_terminal_state(pane_id, |terminal| { + Some(terminal.set_detected_agent_process_at(agent, observed_at)) + }) + .into_iter() + .collect(), AppEvent::StateChanged { pane_id, agent, @@ -2953,11 +2968,20 @@ impl AppState { .clone(); let previous_seen = self.workspaces[ws_idx].pane_state(pane_id)?.seen; let now = Instant::now(); - let (mutation, managed_changed, agent_name_changed, unchanged_change) = { + let ( + mutation, + managed_changed, + agent_name_changed, + unchanged_change, + managed_launch_pending, + suppress_acquisition_completion, + ) = { let terminal = self.terminals.get_mut(&terminal_id)?; let previous_agent_name = terminal.agent_name.clone(); + let managed_launch_pending = terminal.managed_agent_launch_pending(); let mutation = update(terminal)?; let managed_changed = terminal.reconcile_managed_agent_at(now, false); + let suppress_acquisition_completion = terminal.finish_agent_process_acquisition(); let agent_name_changed = terminal.agent_name != previous_agent_name; let unchanged_change = (mutation.agent_released || agent_name_changed) .then(|| terminal.unchanged_effective_state_change_at(now)); @@ -2966,6 +2990,8 @@ impl AppState { managed_changed, agent_name_changed, unchanged_change, + managed_launch_pending, + suppress_acquisition_completion, ) }; if mutation.session_ref_changed || managed_changed || agent_name_changed { @@ -2973,13 +2999,15 @@ impl AppState { } let agent_released = mutation.agent_released; let change = mutation.effective_state_change.or(unchanged_change)?; + let suppress_completion = change.state == AgentState::Idle + && (managed_launch_pending || suppress_acquisition_completion); if change.previous_state != change.state { self.next_agent_state_change_seq += 1; if let Some(terminal) = self.terminals.get_mut(&terminal_id) { terminal.last_agent_state_change_seq = Some(self.next_agent_state_change_seq); } } - let seen = self.apply_pane_state_change(ws_idx, pane_id, &change)?; + let seen = self.apply_pane_state_change(ws_idx, pane_id, &change, suppress_completion)?; let update = PaneStateUpdate { pane_id, ws_idx, @@ -3004,6 +3032,7 @@ impl AppState { agent_name_changed, agent_released, agent_release_status: agent_released.then(|| pane_agent_status(change.state, seen)), + suppress_completion, }; Some(update) } @@ -3070,6 +3099,7 @@ impl AppState { ws_idx: usize, pane_id: PaneId, change: &EffectiveStateChange, + suppress_completion: bool, ) -> Option { let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id); let suppress_active_tab_notifications = @@ -3081,13 +3111,17 @@ impl AppState { if change.state != AgentState::Idle { pane.seen = true; - } else if is_completion_transition(change) { + } else if !suppress_completion && is_completion_transition(change) { pane.seen = suppress_active_tab_notifications; } let seen = pane.seen; - if let Some(delivery) = self.record_or_deliver_agent_notification(ws_idx, pane_id, change) { - self.apply_agent_notification_delivery(&delivery); + if !suppress_completion { + if let Some(delivery) = + self.record_or_deliver_agent_notification(ws_idx, pane_id, change) + { + self.apply_agent_notification_delivery(&delivery); + } } Some(seen) @@ -4963,6 +4997,97 @@ mod tests { assert!(!pane.seen); } + #[test] + fn first_idle_after_process_detection_is_not_completion() { + let mut state = app_with_workspaces(&["active", "background"]); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + state.active = Some(0); + let pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Pi, + observed_at: Instant::now(), + }); + let direct_idle = state + .handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: Instant::now(), + }) + .pop() + .expect("direct idle state update"); + assert!(direct_idle.suppress_completion); + + state.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Pi, + observed_at: Instant::now(), + }); + for agent_state in [AgentState::Working, AgentState::Blocked] { + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: agent_state, + visible_blocker: agent_state == AgentState::Blocked, + visible_working: agent_state == AgentState::Working, + process_exited: false, + observed_at: Instant::now(), + }); + } + let update = state + .handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: Instant::now(), + }) + .pop() + .expect("idle state update"); + + assert!(update.suppress_completion); + assert!(state.workspaces[1].panes[&pane_id].seen); + assert!(!matches!( + state.toast.as_ref().map(|toast| toast.kind), + Some(ToastKind::Finished) + )); + + state.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Codex, + observed_at: Instant::now(), + }); + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Codex), + state: AgentState::Working, + visible_blocker: false, + visible_working: true, + process_exited: false, + observed_at: Instant::now(), + }); + let exit_update = state + .handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Codex), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: true, + observed_at: Instant::now(), + }) + .pop() + .expect("process exit update"); + assert!(!exit_update.suppress_completion); + } + #[test] fn waiting_sound_plays_even_in_active_workspace() { assert_eq!( diff --git a/src/app/api.rs b/src/app/api.rs index 0ccd9b2162..4c075ea39b 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -101,13 +101,20 @@ impl App { } pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) { + let _ = self.handle_internal_event_with_pane_updates(ev); + } + + pub(crate) fn handle_internal_event_with_pane_updates( + &mut self, + ev: AppEvent, + ) -> Vec { if let AppEvent::TerminalBell { count, .. } = ev { if let Err(err) = crate::terminal_effects::write_terminal_bells(&mut std::io::stdout(), count) { tracing::warn!(err = %err, "failed to emit terminal bell"); } - return; + return Vec::new(); } if let AppEvent::ClipboardWrite { content } = ev { @@ -116,7 +123,7 @@ impl App { #[cfg(test)] let _ = content; self.show_clipboard_feedback(); - return; + return Vec::new(); } if let AppEvent::PrefixInputSource { active } = ev { @@ -125,14 +132,14 @@ impl App { // App-internal drain consume the event before the forwarding drain, the flag keeps the // switch out of the headless server process. if !self.local_input_source_switch { - return; + return Vec::new(); } if active { self.prefix_input_source.switch_to_ascii(); } else { self.prefix_input_source.restore(); } - return; + return Vec::new(); } if let AppEvent::GitStatusRefreshed { @@ -141,7 +148,7 @@ impl App { } = ev { self.handle_git_status_refreshed(results, cache_updates); - return; + return Vec::new(); } if let AppEvent::PluginCommandFinished { @@ -172,17 +179,17 @@ impl App { crate::api::schema::PluginCommandStatus::Failed }; } - return; + return Vec::new(); } if let AppEvent::WorktreeAddFinished(result) = ev { self.handle_worktree_add_finished(*result); - return; + return Vec::new(); } if let AppEvent::WorktreeRemoveFinished(result) = ev { self.handle_worktree_remove_finished(*result); - return; + return Vec::new(); } if let AppEvent::PaneDied { pane_id } = &ev { @@ -193,7 +200,7 @@ impl App { .is_some_and(|popup| popup.pane_id == *pane_id) { self.close_popup_pane(); - return; + return Vec::new(); } let previous_toast = self.state.toast.clone(); if let Some(update) = self.state.publish_pane_process_exit_if_agent(*pane_id) { @@ -208,7 +215,7 @@ impl App { self.overlay_panes.remove(pane_id); self.render_dirty.request_generic(); self.render_notify.notify_one(); - return; + return Vec::new(); } } @@ -353,6 +360,7 @@ impl App { self.sync_toast_deadline(previous_toast); self.shutdown_detached_terminal_runtimes(); + pane_updates } fn reset_agent_detection_for_agents(&self, agents: &[crate::detect::Agent]) { diff --git a/src/cli/agent.rs b/src/cli/agent.rs index e1271f4872..362c9ab181 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -543,16 +543,26 @@ fn wait_for_named_agent( ))) } else if agent["name"].as_str() != Some(name) { Some(Err(agent_name_lost_error("cli:agent:start", name))) - } else if agent["interactive_ready"].as_bool().unwrap_or(false) { - Some(Ok(agent.clone())) - } else if !agent["launch_pending"].as_bool().unwrap_or(false) { - Some(Err(cli_agent_error( - "cli:agent:start", - "agent_start_failed", - "agent process exited before becoming interactive", - ))) } else { - None + match agent["agent_status"].as_str() { + Some("blocked") => Some(Err(cli_agent_error( + "cli:agent:start", + "agent_not_ready", + format!("agent {name} is blocked during startup and is not ready for prompts"), + ))), + Some("working" | "unknown") => None, + Some("idle" | "done") if agent["interactive_ready"].as_bool() == Some(true) => { + Some(Ok(agent.clone())) + } + Some("idle" | "done") if !agent["launch_pending"].as_bool().unwrap_or(false) => { + Some(Err(cli_agent_error( + "cli:agent:start", + "agent_start_failed", + "agent process exited before becoming interactive", + ))) + } + _ => None, + } }; if let Some(outcome) = outcome { return Ok(outcome); diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index 11c92f49d3..265dfa69e5 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -733,6 +733,40 @@ fn codex_osc_title_plain_is_idle() { assert!(result.visible_idle); } +#[test] +fn codex_trust_directory_requires_live_top_region() { + let screen = "> You are in C:\\Users\\user\\project\n\n\ + Do you trust the contents of this\n\ + directory? Working with untrusted\n\ + contents comes with higher risk of\n\ + prompt injection. Trusting the\n\ + directory allows project-local config,\n\ + hooks, and exec policies to load.\n\n\ + › 1. Yes, continue\n\ + 2. No, quit\n\n\ + Press enter to continue\n"; + let result = osc_explain(Agent::Codex, screen, "project", ""); + + assert_eq!(result.state, AgentState::Blocked); + assert_eq!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("trust_directory") + ); + assert!(result.visible_blocker); + + let transcript = "› > You are in C:\\Users\\user\\project\n\n\ + Do you trust the contents of this\n\ + directory? Working with untrusted contents comes with higher risk.\n"; + let result = osc_explain(Agent::Codex, transcript, "project", ""); + + assert_eq!(result.state, AgentState::Idle); + assert_ne!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("trust_directory") + ); + assert!(!result.visible_blocker); +} + #[test] fn codex_background_terminal_screen_does_not_override_osc_idle() { // Background terminal tasks can be long-lived helpers such as dev servers. diff --git a/src/detect/manifests/codex.toml b/src/detect/manifests/codex.toml index a4a4ca9fa2..9169e10848 100644 --- a/src/detect/manifests/codex.toml +++ b/src/detect/manifests/codex.toml @@ -1,7 +1,7 @@ id = "codex" -version = "2026.07.18.1" -min_engine_version = 2 -updated_at = "2026-07-18T00:00:00Z" +version = "2026.08.09.1" +min_engine_version = 3 +updated_at = "2026-08-09T00:00:00Z" [[rules]] id = "osc_title_blocked" @@ -31,6 +31,17 @@ any = [ { contains = ["esc/← to edit prev"] }, ] +[[rules]] +id = "trust_directory" +state = "blocked" +priority = 950 +region = "top_non_empty_lines(20)" +visible_blocker = true +all = [ + { regex = ['\A> You are in [^\r\n]+(?:\r?\n|$)'] }, + { regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] }, +] + [[rules]] id = "live_strong_blocker" state = "blocked" diff --git a/src/events.rs b/src/events.rs index 7510ae3c61..7a91bea7ba 100644 --- a/src/events.rs +++ b/src/events.rs @@ -56,6 +56,12 @@ pub struct WorktreeRemoveResult { pub enum AppEvent { /// A pane's child process exited. PaneDied { pane_id: PaneId }, + /// Process detection identified an agent before its screen state was confirmed. + AgentProcessDetected { + pane_id: PaneId, + agent: Agent, + observed_at: Instant, + }, /// Fallback detector state changed in a pane. StateChanged { pane_id: PaneId, diff --git a/src/pane.rs b/src/pane.rs index b2c01f016a..1d6c314141 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -201,6 +201,28 @@ async fn publish_state_changed_event( } } +async fn publish_agent_process_detected_event( + state_events: mpsc::Sender, + pane_id: PaneId, + agent: Agent, + observed_at: std::time::Instant, +) { + if let Err(e) = state_events + .send(AppEvent::AgentProcessDetected { + pane_id, + agent, + observed_at, + }) + .await + { + warn!( + pane = pane_id.raw(), + err = %e, + "failed to deliver AgentProcessDetected event" + ); + } +} + #[derive(Debug, Clone, Copy)] struct AgentDetectionPublishUpdate { state: AgentState, @@ -777,21 +799,17 @@ fn spawn_basic_detection_task( // A new foreground agent must not inherit OSC // title/progress evidence from the previous process. terminal.clear_agent_osc_state(); - if agent.is_some() { + if let Some(agent) = agent { agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); - state = AgentState::Idle; - last_visible_idle = true; + state = AgentState::Unknown; + last_visible_idle = false; last_visible_blocker = false; last_visible_working = false; last_visible_signal_refresh = None; - publish_state_changed_event( + publish_agent_process_detected_event( state_events.clone(), pane_id, agent, - AgentState::Idle, - false, - false, - false, now, ) .await; @@ -2272,22 +2290,18 @@ impl PaneRuntime { // A new foreground agent must not inherit OSC // title/progress evidence from the previous process. terminal.clear_agent_osc_state(); - if agent.is_some() { + if let Some(agent) = agent { agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); - state = AgentState::Idle; - last_visible_idle = true; + state = AgentState::Unknown; + last_visible_idle = false; last_visible_blocker = false; last_visible_working = false; last_visible_signal_refresh = None; - publish_state_changed_event( + publish_agent_process_detected_event( state_events.clone(), pane_id, agent, - AgentState::Idle, - false, - false, - false, now, ) .await; diff --git a/src/server/headless.rs b/src/server/headless.rs index 61c825b8da..795e6f1783 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -1890,7 +1890,7 @@ impl HeadlessServer { let suppress_active_tab_notifications = self.active_tab_suppresses_notifications(is_active_tab); - if self.app.state.sound.allows(update.known_agent) { + if !update.suppress_completion && self.app.state.sound.allows(update.known_agent) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change_with_agent_labels( suppress_active_tab_notifications, @@ -2166,7 +2166,11 @@ impl HeadlessServer { // Headless mode disables local sound playback separately from the // sound policy so reloads can keep server-side notification policy live. self.sync_foreground_client_state(); - self.app.handle_internal_event(ev); + let suppress_completion = self + .app + .handle_internal_event_with_pane_updates(ev) + .iter() + .any(|update| update.pane_id == pane_id_val && update.suppress_completion); // Forward sound notification to clients when server-side sound policy allows it. let is_active_tab = self @@ -2185,7 +2189,8 @@ impl HeadlessServer { let next_state = self.pane_effective_state(pane_id_val); let next_agent_label = self.pane_effective_agent_label(pane_id_val); - if self.app.state.toast_config.delay_seconds == 0 + if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent_val) { if let Some(sound) = @@ -2205,7 +2210,8 @@ impl HeadlessServer { } } - let toast_msg = if self.app.state.toast_config.delay_seconds == 0 + let toast_msg = if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 && should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if self.app.state.toast.is_some() && self.app.state.toast != toast_before { @@ -2257,7 +2263,11 @@ impl HeadlessServer { let prev_agent_label = self.pane_effective_agent_label(pane_id_val); self.sync_foreground_client_state(); - self.app.handle_internal_event(ev); + let suppress_completion = self + .app + .handle_internal_event_with_pane_updates(ev) + .iter() + .any(|update| update.pane_id == pane_id_val && update.suppress_completion); // Forward sound notification based on the effective transition when // server-side sound policy allows it. @@ -2277,7 +2287,8 @@ impl HeadlessServer { let next_state = self.pane_effective_state(pane_id_val); let next_agent_label = self.pane_effective_agent_label(pane_id_val); - if self.app.state.toast_config.delay_seconds == 0 + if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent_val) { if let Some(sound) = @@ -2297,7 +2308,8 @@ impl HeadlessServer { } } - let toast_msg = if self.app.state.toast_config.delay_seconds == 0 + let toast_msg = if !suppress_completion + && self.app.state.toast_config.delay_seconds == 0 && should_forward_toast_to_clients(self.app.state.toast_config.delivery) { if self.app.state.toast.is_some() && self.app.state.toast != toast_before { @@ -10624,6 +10636,65 @@ next_tab = "" } } + #[test] + fn startup_idle_does_not_forward_completion() { + let mut server = test_headless_server(); + let workspace = crate::workspace::Workspace::test_new("active"); + let pane_id = workspace.tabs[0].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.toast_config.delivery = crate::config::ToastDelivery::System; + server.app.state.toast_config.delay_seconds = 0; + server.app.state.sound.enabled = true; + + assert!( + server.handle_internal_event_with_forwarding(AppEvent::AgentProcessDetected { + pane_id, + agent: crate::detect::Agent::Pi, + observed_at: Instant::now(), + }) + ); + + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + Some(false), + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + while client_control_rx + .recv_timeout(Duration::from_millis(20)) + .is_ok() + {} + + assert!( + server.handle_internal_event_with_forwarding(AppEvent::StateChanged { + pane_id, + agent: Some(crate::detect::Agent::Pi), + state: crate::detect::AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: Instant::now(), + }) + ); + assert!( + client_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "startup readiness should not forward a completion notification" + ); + } + #[test] fn delayed_agent_notification_forwards_after_deadline() { let mut server = test_headless_server(); diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 5ed5adc48f..74e9267010 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -65,6 +65,7 @@ enum ManagedAgentPhase { deadline: Instant, observed_expected: bool, }, + Blocked, Active, } @@ -144,6 +145,7 @@ pub struct TerminalState { pub launch_argv: Option>, pub respawn_shell_on_exit: bool, recent_agent_process_exit: Option, + agent_process_acquisition_pending: bool, pub pending_agent_resume_plan: Option, } @@ -177,10 +179,43 @@ impl TerminalState { launch_argv: None, respawn_shell_on_exit: false, recent_agent_process_exit: None, + agent_process_acquisition_pending: false, pending_agent_resume_plan: None, } } + pub fn set_detected_agent_process_at( + &mut self, + agent: Agent, + now: Instant, + ) -> TerminalStateMutation { + let starts_acquisition = !self + .should_ignore_detected_state_under_full_lifecycle_hook(Some(agent), false) + && !self.detected_state_observed_before_release_suppression(Some(agent), now); + let mutation = self.set_detected_state_with_screen_signals_at( + Some(agent), + AgentState::Unknown, + false, + false, + false, + false, + now, + ); + if starts_acquisition { + self.agent_process_acquisition_pending = true; + } + mutation + } + + pub(crate) fn finish_agent_process_acquisition(&mut self) -> bool { + let reached_idle = self.agent_process_acquisition_pending && self.state == AgentState::Idle; + let suppress_completion = reached_idle && self.recent_agent_process_exit.is_none(); + if reached_idle { + self.agent_process_acquisition_pending = false; + } + suppress_completion + } + pub(crate) fn terminal_title_stripped(&self) -> Option { self.terminal_title .as_deref() @@ -1818,8 +1853,12 @@ impl TerminalState { } pub fn managed_agent_launch_pending(&self) -> bool { - self.managed_agent - .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Pending { .. })) + self.managed_agent.is_some_and(|managed| { + matches!( + managed.phase, + ManagedAgentPhase::Pending { .. } | ManagedAgentPhase::Blocked + ) + }) } pub fn managed_agent_interactive_ready(&self) -> bool { @@ -1852,7 +1891,7 @@ impl TerminalState { ManagedAgentPhase::Pending { observed_expected, .. } => observed_expected || known_agent == Some(managed.kind), - ManagedAgentPhase::Active => false, + ManagedAgentPhase::Blocked | ManagedAgentPhase::Active => false, }; let clear = process_exited || known_agent.is_some_and(|agent| agent != managed.kind) @@ -1863,20 +1902,35 @@ impl TerminalState { self.clear_agent_name(); return true; } + if managed.phase == ManagedAgentPhase::Blocked { + if known_agent == Some(managed.kind) && self.state == AgentState::Idle { + self.managed_agent = Some(ManagedAgent { + kind: managed.kind, + phase: ManagedAgentPhase::Active, + }); + return true; + } + return false; + } if let ManagedAgentPhase::Pending { ready_after, deadline, observed_expected: previous_observed_expected, } = managed.phase { + if known_agent == Some(managed.kind) && self.state == AgentState::Blocked { + self.managed_agent = Some(ManagedAgent { + kind: managed.kind, + phase: ManagedAgentPhase::Blocked, + }); + return true; + } if now >= deadline { self.clear_agent_name(); return true; } if ready_after.is_none_or(|ready_after| now >= ready_after) { - if known_agent == Some(managed.kind) - && matches!(self.state, AgentState::Idle | AgentState::Blocked) - { + if known_agent == Some(managed.kind) && self.state == AgentState::Idle { self.managed_agent = Some(ManagedAgent { kind: managed.kind, phase: ManagedAgentPhase::Active, @@ -1944,6 +1998,7 @@ impl TerminalState { self.launch_argv = None; self.respawn_shell_on_exit = false; self.recent_agent_process_exit = None; + self.agent_process_acquisition_pending = false; self.pending_agent_resume_plan = None; self.clear_agent_name(); } @@ -2084,7 +2139,7 @@ mod tests { } #[test] - fn managed_agent_activates_only_after_matching_settled_detection() { + fn managed_agent_readiness_tracks_detection_state() { let mut terminal = test_terminal(); let now = Instant::now(); terminal.begin_managed_agent( @@ -2094,23 +2149,35 @@ mod tests { Duration::from_millis(100), Duration::from_secs(1), ); - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Unknown); assert!(terminal.managed_agent_launch_pending()); assert!(!terminal.managed_agent_interactive_ready()); assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(100), false)); - assert!(!terminal.managed_agent_launch_pending()); - assert!(terminal.managed_agent_interactive_ready()); - assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert!(terminal.managed_agent_launch_pending()); terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + assert!(!terminal.reconcile_managed_agent_at(now + Duration::from_millis(101), false)); + + terminal.set_detected_state(Some(Agent::Pi), AgentState::Blocked); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(102), false)); + assert!(terminal.managed_agent_launch_pending()); + assert!(!terminal.managed_agent_interactive_ready()); + assert_eq!(terminal.next_managed_agent_deadline(), None); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert!(!terminal.reconcile_managed_agent_at(now + Duration::from_secs(2), false)); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_secs(2), false)); + assert!(!terminal.managed_agent_launch_pending()); assert!(terminal.managed_agent_interactive_ready()); terminal.set_detected_state(None, AgentState::Unknown); assert!(terminal.managed_agent_interactive_ready()); - assert!(!terminal.reconcile_managed_agent_at(now + Duration::from_millis(101), false)); + assert!(!terminal.reconcile_managed_agent_at(now + Duration::from_secs(2), false)); assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); - assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(102), true)); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_secs(2), true)); assert_eq!(terminal.agent_name, None); } @@ -5235,6 +5302,7 @@ mod tests { session_ref: crate::agent_resume::AgentSessionRef::id("codex-session").unwrap(), }); terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); + terminal.set_detected_agent_process_at(Agent::Codex, Instant::now()); terminal.clear_agent_runtime_identity_after_respawn(); @@ -5243,6 +5311,7 @@ mod tests { assert!(terminal.agent_name.is_none()); assert!(terminal.persisted_agent_session.is_none()); assert!(!terminal.respawn_shell_on_exit); + assert!(!terminal.finish_agent_process_acquisition()); } #[test] diff --git a/tests/cli/agent_transport.rs b/tests/cli/agent_transport.rs index 42e67828e3..85419e90ee 100644 --- a/tests/cli/agent_transport.rs +++ b/tests/cli/agent_transport.rs @@ -1,7 +1,7 @@ use super::harness::*; #[test] -fn agent_start_accepts_durable_readiness_during_detection_gap() { +fn agent_start_waits_through_unknown_then_rejects_blocked() { let base = unique_test_dir(); fs::create_dir_all(&base).unwrap(); let socket_path = base.join("herdr.sock"); @@ -55,6 +55,32 @@ fn agent_start_accepts_durable_readiness_during_detection_gap() { ) .unwrap(); get_stream.flush().unwrap(); + + let (mut get_stream, get_line) = accept_fake_cli_operation(&listener); + let get: serde_json::Value = serde_json::from_str(&get_line).unwrap(); + assert_eq!(get["method"], "agent.get"); + assert_eq!(get["params"]["target"], "reviewer"); + writeln!( + get_stream, + "{}", + serde_json::json!({ + "id": get["id"], + "result": { + "type": "agent_info", + "agent": { + "agent": "opencode", + "agent_status": "blocked", + "interactive_ready": true, + "launch_pending": false, + "name": "reviewer", + "pane_id": "w1:p1", + "terminal_id": "term_1" + } + } + }) + ) + .unwrap(); + get_stream.flush().unwrap(); }); let started = run_cli( @@ -63,17 +89,10 @@ fn agent_start_accepts_durable_readiness_during_detection_gap() { "agent", "start", "reviewer", "--kind", "opencode", "--pane", "w1:p1", ], ); - assert!( - started.status.success(), - "stderr: {}", - String::from_utf8_lossy(&started.stderr) - ); - let started: serde_json::Value = serde_json::from_slice(&started.stdout).unwrap(); - assert_eq!(started["result"]["type"], "agent_started"); - assert_eq!(started["result"]["agent"]["name"], "reviewer"); - assert!(started["result"]["agent"]["interactive_ready"] - .as_bool() - .unwrap()); + assert_eq!(started.status.code(), Some(1)); + assert!(started.stdout.is_empty()); + let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap(); + assert_eq!(error["error"]["code"], "agent_not_ready"); server.join().unwrap(); cleanup_test_base(&base); diff --git a/website/agent-detection/codex.toml b/website/agent-detection/codex.toml index a4a4ca9fa2..9169e10848 100644 --- a/website/agent-detection/codex.toml +++ b/website/agent-detection/codex.toml @@ -1,7 +1,7 @@ id = "codex" -version = "2026.07.18.1" -min_engine_version = 2 -updated_at = "2026-07-18T00:00:00Z" +version = "2026.08.09.1" +min_engine_version = 3 +updated_at = "2026-08-09T00:00:00Z" [[rules]] id = "osc_title_blocked" @@ -31,6 +31,17 @@ any = [ { contains = ["esc/← to edit prev"] }, ] +[[rules]] +id = "trust_directory" +state = "blocked" +priority = 950 +region = "top_non_empty_lines(20)" +visible_blocker = true +all = [ + { regex = ['\A> You are in [^\r\n]+(?:\r?\n|$)'] }, + { regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] }, +] + [[rules]] id = "live_strong_blocker" state = "blocked" From 62d7eb72e9302dfde489ce8b7daceab18bc26c51 Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Mon, 10 Aug 2026 13:48:11 +0200 Subject: [PATCH 2/2] fix: return pane updates after tab bar command refs #2410 --- src/app/api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api.rs b/src/app/api.rs index 02527a8b90..5021554777 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -163,7 +163,7 @@ impl App { } = ev { let _ = self.handle_tab_bar_command_finished(generation, segment_index, result); - return; + return Vec::new(); } if let AppEvent::PluginCommandFinished {