feat: use pty-first agent detection

This commit is contained in:
Ogulcan Celik 2026-06-08 20:55:51 +03:00
parent a7cd7806f2
commit ee51c5e5fb
35 changed files with 2184 additions and 1034 deletions

View File

@ -7,39 +7,46 @@ Herdr is built for running more than one coding agent at a time. Each agent stay
## Supported agents
Automatic detection works out of the box for common coding agents.
Automatic detection works out of the box for common coding agents. The important difference is not whether Herdr can see an agent. It is which signal is allowed to author `idle`, `working`, and `blocked`.
| Agent | Idle / done | Working | Blocked |
| --- | --- | --- | --- |
| Pi | yes | yes | partial |
| Claude Code | yes | yes | yes |
| Codex | yes | yes | yes |
| Droid | yes | yes | yes |
| Amp | yes | yes | yes |
| OpenCode | yes | yes | yes |
| Grok CLI | yes | yes | yes |
| Hermes Agent | yes | yes | yes |
| Kilo Code CLI | yes | yes | yes |
| Cursor Agent | yes | yes | yes |
| Antigravity CLI | yes | yes | yes |
| Kimi Code CLI | yes | yes | yes |
| Kiro CLI | yes | yes | no |
| GitHub Copilot CLI | yes | yes | yes |
| Qoder CLI | yes | yes | yes |
| Agent | State authority | Integration role |
| --- | --- | --- |
| Pi | lifecycle hooks when installed; otherwise PTY/screen | state and session |
| OMP | lifecycle hooks when installed | state |
| GitHub Copilot CLI | lifecycle hooks when installed; otherwise PTY/screen | state and session |
| Kimi Code CLI | lifecycle hooks when installed; otherwise PTY/screen | state |
| Hermes Agent | lifecycle hooks when installed; otherwise PTY/screen | state and session |
| Qoder CLI | lifecycle hooks when installed; otherwise PTY/screen | state |
| Claude Code | PTY/screen | session |
| Codex | PTY/screen | session |
| Cursor Agent CLI | PTY/screen | session |
| Droid | PTY/screen | session |
| OpenCode | PTY/screen | session |
| Amp | PTY/screen | none |
| Grok CLI | PTY/screen | none |
| Kilo Code CLI | PTY/screen | none |
| Antigravity CLI | PTY/screen | none |
| Kiro CLI | PTY/screen | none |
Detected but less thoroughly tested: Gemini CLI and Cline.
Detected but less thoroughly tested: Gemini CLI and Cline. Unsupported agents still run normally as terminal processes. They just may not get rich state unless you add an integration or report state over the socket API.
Unsupported agents still run normally as terminal processes. They just may not get rich state unless you add an integration or report state over the socket API.
## Status authority
## How detection works
Herdr first detects the foreground process in each pane. After that, each pane has one status authority.
Herdr combines three signals:
For agents with complete lifecycle hooks, the integration is authoritative when it is installed and actively reporting for the running pane. Herdr uses those hook reports for `idle`, `working`, `blocked`, and session identity. It does not also run PTY/screen state fallback for that same lifecycle authority. This avoids two competing sources of truth.
1. foreground process detection
2. terminal output heuristics
3. integration session identity or state reports
For agents without complete lifecycle hooks, Herdr uses PTY-first detection. Terminal render activity means the agent is probably `working`. When the terminal becomes quiet, Herdr reads the recent pane screen and asks one strict question: does the visible agent UI match a known blocked prompt? If yes, the pane is `blocked`. If not, the pane is `idle`.
Process detection tells Herdr which pane owns an agent. Heuristics infer native agent state from terminal screen snapshots. Claude Code, Codex, Droid, and OpenCode integrations provide session identity for restore; Pi, OMP, GitHub Copilot CLI, Kimi Code CLI, Hermes Agent, Qoder CLI, and custom socket integrations can report state when they define state outside the native terminal UI.
Herdr briefly ignores detection after a new agent process appears, and after input, mouse events, or pane resize. Those events can make a terminal UI redraw even when the agent did not resume work. Once the quiet window passes, normal detection starts again from fresh PTY activity.
Claude Code, Codex, Cursor Agent CLI, Droid, and OpenCode integrations are intentionally not lifecycle authorities. They provide native session identity for restore, but their hooks do not cover the whole lifecycle. They can miss permission approval results, escape interrupts, or other transitions. For those agents, Herdr still uses PTY/screen state detection.
## Blocked state
Blocked detection is deliberately strict for PTY/screen agents. Herdr treats active terminal output as `working`; it only asks whether the agent is blocked after PTY activity becomes quiet. If the screen does not match a known blocked prompt shape, Herdr falls back to `idle`.
This means unusual new agent prompts may initially show as `idle` instead of `blocked` until Herdr learns that screen shape. It also means opening an agent menu, moving a selection, or resizing a pane may delay state changes briefly. Those interactions should not make Herdr send input or take destructive action; they only affect the visible status and waits.
Herdr can run inside tmux as the outer terminal environment. Agent detection does not inspect tmux sessions launched inside a Herdr pane. If a shell framework auto-enters tmux inside Herdr, Herdr sees `tmux` as the pane process instead of the agent behind it.

View File

@ -25,7 +25,7 @@ Panes can be split right or down. They can be renamed manually, read from the CL
## Agent
An agent is a process Herdr recognizes inside a pane. Herdr detects agents from foreground processes, screen heuristics, and optional integrations.
An agent is a process Herdr recognizes inside a pane. Herdr detects agents from foreground processes, PTY/screen activity, and optional integrations.
Agent states are:

View File

@ -3,9 +3,9 @@ title: Integrations
description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, OpenCode, Hermes Agent, Qoder CLI, and Cursor Agent CLI.
---
Herdr detects supported agents automatically. Official integrations can add native session identity for restore, semantic state reports, or both. Claude Code, Codex, Droid, and OpenCode state comes from screen detection even when their session identity integrations are installed.
Herdr detects supported agents automatically. Official integrations can add native session identity for restore, lifecycle state reports, or both.
Use integrations when you want native agent session restore, direct state reports from Pi/OMP/Copilot/Kimi/Hermes/Qoder-style hooks or plugins, or both.
Use integrations when you want native agent session restore, direct lifecycle reports from Pi/OMP/Copilot/Kimi/Hermes/Qoder-style hooks or plugins, or both. See [Agents](/docs/agents/) for the full status authority model.
## Install integrations
@ -43,15 +43,14 @@ herdr integration uninstall cursor
## How Herdr uses integrations
Herdr combines three signals:
Herdr uses integrations in two different ways:
| Signal | Purpose |
| --- | --- |
| Process detection | Identifies which pane owns which running process. |
| Screen heuristics | Infers visible native agent state such as `working` or `blocked`. |
| Integration events | Reports native session identity, semantic state, or both depending on the integration. |
| Integration type | Agents | Effect |
| --- | --- | --- |
| Lifecycle authority | Pi, OMP, GitHub Copilot CLI, Kimi Code CLI, Hermes Agent, Qoder CLI | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use PTY/screen fallback for that same lifecycle authority. |
| Session identity | Claude Code, Codex, Cursor Agent CLI, Droid, OpenCode | The integration reports native session references for restore. State still comes from Herdr's PTY/screen detection. |
Claude Code, Codex, Cursor Agent CLI, Droid, and OpenCode state detection is screen-read first. Their Herdr-owned integrations do not author `idle`, `working`, or `blocked` state. Pi, GitHub Copilot CLI, and Hermes Agent report semantic state and session identity. OMP, Kimi Code CLI, and Qoder CLI report semantic state but do not provide native session restore. Custom socket integrations can report state when they define state that is not visible in the native terminal UI.
Custom socket integrations can also report state when they define state that is not visible in the native terminal UI.
Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Cursor Agent CLI, GitHub Copilot CLI, Droid, Pi, Hermes Agent, and OpenCode panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it.
@ -99,7 +98,7 @@ Install the Claude Code hook:
herdr integration install claude
```
The hook reports Claude Code session identity to the local Herdr socket on session start. Claude Code state comes from Herdr's screen detection.
The hook reports Claude Code session identity to the local Herdr socket on session start. Claude Code state comes from Herdr's PTY/screen detection.
Herdr uses `~/.claude` by default, or `CLAUDE_CONFIG_DIR` when set. The Claude config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and updates `settings.json` with Herdr hook entries. Uninstall removes the matching hook entries and deletes the hook script.
@ -111,7 +110,7 @@ Install the Codex hook:
herdr integration install codex
```
The Codex hook reports session identity through the same local socket API used by other integrations. Codex state comes from Herdr's screen detection.
The Codex hook reports session identity through the same local socket API used by other integrations. Codex state comes from Herdr's PTY/screen detection.
Herdr uses `~/.codex` by default, or `CODEX_HOME` when set. The Codex config directory must already exist. Install writes `herdr-agent-state.sh`, updates `hooks.json`, and ensures `[features] hooks = true` in `config.toml`. It also removes the deprecated top-level `codex_hooks` flag when present. Uninstall removes Herdr entries from `hooks.json` and deletes the hook script, but leaves `config.toml` unchanged.
@ -141,7 +140,7 @@ The hook reports semantic state to Herdr when Kimi Code emits lifecycle, tool, a
Herdr uses `~/.kimi-code` by default, or `KIMI_CODE_HOME` when set. The Kimi Code config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and appends Herdr-managed `[[hooks]]` entries to `config.toml`. Uninstall removes the Herdr-managed config block and deletes the hook script.
Native screen heuristics remain available when the hook is not installed.
Native PTY/screen detection remains available when the hook is not installed.
## Droid
@ -151,7 +150,7 @@ Install the Droid hook:
herdr integration install droid
```
The Droid hook reports session identity through the same local socket API used by other integrations. Droid state comes from Herdr's screen detection.
The Droid hook reports session identity through the same local socket API used by other integrations. Droid state comes from Herdr's PTY/screen detection.
Herdr uses `~/.factory` for Droid hooks. The Factory config directory must already exist. Install writes `hooks/herdr-agent-state.sh`, updates `settings.json` with a Herdr `SessionStart` hook entry, and removes older Herdr Droid hook entries from `hooks.json` if present. Uninstall removes Herdr entries from both config files and deletes the hook script.
@ -167,7 +166,7 @@ herdr integration install opencode
Herdr writes the plugin to `~/.config/opencode/plugins/herdr-agent-state.js`. The OpenCode config directory must already exist. Uninstall removes only that plugin file.
The plugin reports session identity while OpenCode runs inside a Herdr pane. After OpenCode emits a session-bearing event, Herdr can use the reported session id to resume the pane with `opencode --session <id>`. OpenCode state comes from Herdr's screen detection.
The plugin reports session identity while OpenCode runs inside a Herdr pane. After OpenCode emits a session-bearing event, Herdr can use the reported session id to resume the pane with `opencode --session <id>`. OpenCode state comes from Herdr's PTY/screen detection.
## Hermes Agent
@ -179,7 +178,7 @@ herdr integration install hermes
Herdr writes `~/.hermes/plugins/herdr-agent-state/` and enables `herdr-agent-state` in `~/.hermes/config.yaml`. The Hermes config directory must already exist. Restart Hermes after installing so the plugin loads. Uninstall removes the plugin directory and removes `herdr-agent-state` from `plugins.enabled`.
The plugin reports lifecycle, tool, approval state, and session id while Hermes runs inside a Herdr pane. Herdr can use the reported session id to resume the pane with `hermes --resume <id>`. Native screen heuristics remain available when the plugin is not installed.
The plugin reports lifecycle, tool, approval state, and session id while Hermes runs inside a Herdr pane. Herdr can use the reported session id to resume the pane with `hermes --resume <id>`. Native PTY/screen detection remains available when the plugin is not installed.
## Qoder CLI
@ -193,7 +192,7 @@ The hook reports semantic state to Herdr when Qoder CLI emits lifecycle events.
Herdr uses `~/.qoder` by default, or `QODER_CONFIG_DIR` when set. The Qoder config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and updates `settings.json` with Herdr hook entries. Uninstall removes the matching hook entries and deletes the hook script.
Native screen heuristics remain available when the hook is not installed.
Native PTY/screen detection remains available when the hook is not installed.
## Cursor Agent CLI
@ -203,7 +202,7 @@ Install the Cursor Agent CLI hook:
herdr integration install cursor
```
The hook reports session identity through Cursor's `sessionStart` hook while Cursor Agent CLI runs inside a Herdr pane. Cursor state comes from Herdr's screen detection.
The hook reports session identity through Cursor's `sessionStart` hook while Cursor Agent CLI runs inside a Herdr pane. Cursor state comes from Herdr's PTY/screen detection.
Herdr uses `~/.cursor` by default, or `CURSOR_CONFIG_DIR` when set. The Cursor config directory must already exist. Install writes `herdr-agent-state.sh` and adds a Herdr `sessionStart` entry to `hooks.json`. Uninstall removes the matching hook entry and deletes the hook script.

View File

@ -0,0 +1,288 @@
use crate::detect::{Agent, AgentDetection, AgentState};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PtySignal {
pub active: bool,
pub tainted: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DetectionPolicyInput {
pub agent: Option<Agent>,
pub screen_detection: AgentDetection,
pub process_exited: bool,
pub startup_grace_active: bool,
pub pty_signal: Option<PtySignal>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DetectionPolicyDecision {
Publish(AgentDetection),
Freeze,
}
fn screen_blocked_or_idle_fallback(detection: AgentDetection) -> AgentDetection {
if detection.visible_blocker {
return AgentDetection {
state: AgentState::Blocked,
skip_state_update: false,
visible_blocker: true,
visible_working: false,
};
}
AgentDetection {
state: AgentState::Idle,
skip_state_update: false,
visible_blocker: false,
visible_working: false,
}
}
#[cfg(test)]
pub(crate) fn full_lifecycle_detected_agent(agent: Agent) -> bool {
matches!(
agent,
Agent::Pi | Agent::GithubCopilot | Agent::Kimi | Agent::Hermes | Agent::Qodercli
)
}
pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> bool {
matches!(
(source, agent_label),
("herdr:pi", "pi")
| ("herdr:omp", "omp")
| ("herdr:kimi", "kimi")
| ("herdr:copilot", "copilot")
| ("herdr:hermes", "hermes")
| ("herdr:qodercli", "qodercli")
)
}
pub(crate) fn apply_detection_policy(input: DetectionPolicyInput) -> DetectionPolicyDecision {
if input.process_exited {
return DetectionPolicyDecision::Publish(input.screen_detection);
}
if input.startup_grace_active {
return DetectionPolicyDecision::Freeze;
}
if input.agent.is_none() {
return DetectionPolicyDecision::Publish(input.screen_detection);
};
let Some(pty_signal) = input.pty_signal else {
return DetectionPolicyDecision::Publish(input.screen_detection);
};
if pty_signal.tainted {
return DetectionPolicyDecision::Freeze;
}
if pty_signal.active {
return DetectionPolicyDecision::Publish(AgentDetection {
state: AgentState::Working,
skip_state_update: false,
visible_blocker: false,
visible_working: false,
});
}
DetectionPolicyDecision::Publish(screen_blocked_or_idle_fallback(input.screen_detection))
}
#[cfg(test)]
mod tests {
use super::*;
fn detection(state: AgentState) -> AgentDetection {
AgentDetection {
state,
skip_state_update: false,
visible_blocker: false,
visible_working: state == AgentState::Working,
}
}
fn input(screen_detection: AgentDetection) -> DetectionPolicyInput {
DetectionPolicyInput {
agent: Some(Agent::Codex),
screen_detection,
process_exited: false,
startup_grace_active: false,
pty_signal: Some(PtySignal {
active: false,
tainted: false,
}),
}
}
#[test]
fn classifies_full_lifecycle_hook_sources() {
assert!(full_lifecycle_hook_authority("herdr:pi", "pi"));
assert!(full_lifecycle_hook_authority("herdr:omp", "omp"));
assert!(full_lifecycle_hook_authority("herdr:kimi", "kimi"));
assert!(full_lifecycle_hook_authority("herdr:copilot", "copilot"));
assert!(full_lifecycle_hook_authority("herdr:hermes", "hermes"));
assert!(full_lifecycle_hook_authority("herdr:qodercli", "qodercli"));
assert!(!full_lifecycle_hook_authority("herdr:codex", "codex"));
assert!(!full_lifecycle_hook_authority("herdr:claude", "claude"));
assert!(!full_lifecycle_hook_authority("herdr:opencode", "opencode"));
assert!(!full_lifecycle_hook_authority("custom", "pi"));
}
#[test]
fn classifies_full_lifecycle_detected_agents_without_omp_variant() {
assert!(full_lifecycle_detected_agent(Agent::Pi));
assert!(full_lifecycle_detected_agent(Agent::Kimi));
assert!(full_lifecycle_detected_agent(Agent::GithubCopilot));
assert!(full_lifecycle_detected_agent(Agent::Hermes));
assert!(full_lifecycle_detected_agent(Agent::Qodercli));
assert!(!full_lifecycle_detected_agent(Agent::Codex));
assert!(!full_lifecycle_detected_agent(Agent::Claude));
}
#[test]
fn startup_grace_freezes_publish() {
let mut input = input(detection(AgentState::Working));
input.startup_grace_active = true;
assert_eq!(
apply_detection_policy(input),
DetectionPolicyDecision::Freeze
);
}
#[test]
fn taint_freezes_weak_publish() {
let mut input = input(detection(AgentState::Idle));
input.pty_signal = Some(PtySignal {
active: false,
tainted: true,
});
assert_eq!(
apply_detection_policy(input),
DetectionPolicyDecision::Freeze
);
}
#[test]
fn taint_freezes_visible_blocker_until_pty_is_quiet() {
let mut blocker = detection(AgentState::Blocked);
blocker.visible_blocker = true;
let mut input = input(blocker);
input.pty_signal = Some(PtySignal {
active: false,
tainted: true,
});
assert_eq!(
apply_detection_policy(input),
DetectionPolicyDecision::Freeze
);
}
#[test]
fn process_exit_publishes_even_during_taint() {
let mut input = input(detection(AgentState::Idle));
input.process_exited = true;
input.pty_signal = Some(PtySignal {
active: true,
tainted: true,
});
assert_eq!(
apply_detection_policy(input),
DetectionPolicyDecision::Publish(detection(AgentState::Idle))
);
}
#[test]
fn pty_activity_publishes_working_without_inventing_visible_working() {
let mut input = input(detection(AgentState::Idle));
input.pty_signal = Some(PtySignal {
active: true,
tainted: false,
});
assert_eq!(
apply_detection_policy(input),
DetectionPolicyDecision::Publish(AgentDetection {
state: AgentState::Working,
skip_state_update: false,
visible_blocker: false,
visible_working: false,
})
);
}
#[test]
fn active_pty_wins_over_visible_blocker() {
let mut blocker = detection(AgentState::Blocked);
blocker.visible_blocker = true;
let mut input = input(blocker);
input.pty_signal = Some(PtySignal {
active: true,
tainted: false,
});
assert_eq!(
apply_detection_policy(input),
DetectionPolicyDecision::Publish(AgentDetection {
state: AgentState::Working,
skip_state_update: false,
visible_blocker: false,
visible_working: false,
})
);
}
#[test]
fn silent_pty_with_visible_blocker_publishes_blocked() {
let mut screen = detection(AgentState::Blocked);
screen.visible_blocker = true;
let input = input(screen);
assert_eq!(
apply_detection_policy(input),
DetectionPolicyDecision::Publish(AgentDetection {
state: AgentState::Blocked,
skip_state_update: false,
visible_blocker: true,
visible_working: false,
})
);
}
#[test]
fn silent_pty_downgrades_screen_working_to_idle() {
let input = input(detection(AgentState::Working));
assert_eq!(
apply_detection_policy(input),
DetectionPolicyDecision::Publish(AgentDetection {
state: AgentState::Idle,
skip_state_update: false,
visible_blocker: false,
visible_working: false,
})
);
}
#[test]
fn silent_pty_downgrades_weak_screen_blocked_to_idle() {
let input = input(detection(AgentState::Blocked));
assert_eq!(
apply_detection_policy(input),
DetectionPolicyDecision::Publish(AgentDetection {
state: AgentState::Idle,
skip_state_update: false,
visible_blocker: false,
visible_working: false,
})
);
}
}

View File

@ -22,6 +22,28 @@ fn is_background_completion_transition(prev_state: AgentState, new_state: AgentS
&& matches!(prev_state, AgentState::Working | AgentState::Blocked)
}
fn is_completion_transition(change: &EffectiveStateChange) -> bool {
is_completion_transition_parts(
change.previous_state,
change.state,
change.previous_agent_label.as_deref(),
change.agent_label.as_deref(),
)
}
pub fn is_completion_transition_parts(
previous_state: AgentState,
state: AgentState,
previous_agent_label: Option<&str>,
agent_label: Option<&str>,
) -> bool {
is_background_completion_transition(previous_state, state)
|| (previous_state == AgentState::Unknown
&& state == AgentState::Idle
&& previous_agent_label.is_some()
&& previous_agent_label == agent_label)
}
pub fn active_tab_suppresses_notifications(
is_active_tab: bool,
outer_terminal_focus: Option<bool>,
@ -29,6 +51,7 @@ pub fn active_tab_suppresses_notifications(
is_active_tab && outer_terminal_focus != Some(false)
}
#[cfg(test)]
pub fn notification_sound_for_state_change(
suppress_active_tab_notifications: bool,
prev_state: AgentState,
@ -50,10 +73,58 @@ pub fn notification_sound_for_state_change(
}
}
pub fn notification_toast_for_state_change(
pub fn notification_sound_for_state_change_with_agent_labels(
suppress_active_tab_notifications: bool,
prev_state: AgentState,
new_state: AgentState,
previous_agent_label: Option<&str>,
agent_label: Option<&str>,
) -> Option<crate::sound::Sound> {
if new_state == prev_state {
return None;
}
match new_state {
AgentState::Blocked => Some(crate::sound::Sound::Request),
AgentState::Idle
if is_completion_transition_parts(
prev_state,
new_state,
previous_agent_label,
agent_label,
) && !suppress_active_tab_notifications =>
{
Some(crate::sound::Sound::Done)
}
_ => None,
}
}
fn notification_sound_for_effective_state_change(
suppress_active_tab_notifications: bool,
change: &EffectiveStateChange,
) -> Option<crate::sound::Sound> {
if change.state == change.previous_state {
return None;
}
match change.state {
AgentState::Blocked => Some(crate::sound::Sound::Request),
AgentState::Idle
if is_completion_transition(change) && !suppress_active_tab_notifications =>
{
Some(crate::sound::Sound::Done)
}
_ => None,
}
}
pub fn notification_toast_for_state_change_with_agent_labels(
suppress_active_tab_notifications: bool,
prev_state: AgentState,
new_state: AgentState,
previous_agent_label: Option<&str>,
agent_label: Option<&str>,
) -> Option<ToastKind> {
if suppress_active_tab_notifications || new_state == prev_state {
return None;
@ -61,13 +132,52 @@ pub fn notification_toast_for_state_change(
match new_state {
AgentState::Blocked => Some(ToastKind::NeedsAttention),
AgentState::Idle if is_background_completion_transition(prev_state, new_state) => {
AgentState::Idle
if is_completion_transition_parts(
prev_state,
new_state,
previous_agent_label,
agent_label,
) =>
{
Some(ToastKind::Finished)
}
_ => None,
}
}
fn notification_toast_for_effective_state_change(
suppress_active_tab_notifications: bool,
change: &EffectiveStateChange,
) -> Option<ToastKind> {
if suppress_active_tab_notifications || change.state == change.previous_state {
return None;
}
match change.state {
AgentState::Blocked => Some(ToastKind::NeedsAttention),
AgentState::Idle if is_completion_transition(change) => Some(ToastKind::Finished),
_ => None,
}
}
pub fn notification_toast_for_pane_state_update(
suppress_active_tab_notifications: bool,
update: &PaneStateUpdate,
) -> Option<ToastKind> {
if suppress_active_tab_notifications || update.state == update.previous_state {
return None;
}
notification_toast_for_state_change_with_agent_labels(
suppress_active_tab_notifications,
update.previous_state,
update.state,
update.previous_agent_label.as_deref(),
update.agent_label.as_deref(),
)
}
fn toast_agent_label(agent_label: &str) -> &str {
agent_label
}
@ -2207,7 +2317,6 @@ impl AppState {
agent,
state,
visible_blocker,
visible_idle,
visible_working,
process_exited,
observed_at,
@ -2217,7 +2326,7 @@ impl AppState {
agent,
state,
visible_blocker,
visible_idle,
false,
visible_working,
process_exited,
observed_at,
@ -2405,6 +2514,27 @@ impl AppState {
Some(update)
}
pub(crate) fn publish_pane_process_exit_if_agent(
&mut self,
pane_id: PaneId,
) -> Option<PaneStateUpdate> {
let observed_at = std::time::Instant::now();
self.update_terminal_state(pane_id, |terminal| {
let agent = terminal
.effective_known_agent()
.or(terminal.detected_agent)?;
Some(terminal.set_detected_state_with_screen_signals_at(
Some(agent),
AgentState::Idle,
false,
false,
false,
true,
observed_at,
))
})
}
fn apply_pane_state_change(
&mut self,
ws_idx: usize,
@ -2421,7 +2551,7 @@ impl AppState {
if change.state != AgentState::Idle {
pane.seen = true;
} else if is_background_completion_transition(change.previous_state, change.state) {
} else if is_completion_transition(change) {
pane.seen = suppress_active_tab_notifications;
}
let seen = pane.seen;
@ -2445,15 +2575,13 @@ impl AppState {
let suppress_active_tab_notifications =
active_tab_suppresses_notifications(is_active_tab, self.outer_terminal_focus);
let client_notification_kind = notification_toast_for_state_change(
let client_notification_kind = notification_toast_for_effective_state_change(
suppress_active_tab_notifications,
change.previous_state,
change.state,
change,
);
let sound = notification_sound_for_state_change(
let sound = notification_sound_for_effective_state_change(
suppress_active_tab_notifications,
change.previous_state,
change.state,
change,
);
if client_notification_kind.is_none() && sound.is_none() {
return None;
@ -3834,7 +3962,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Working,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -3854,6 +3981,7 @@ mod tests {
#[test]
fn state_changed_idle_in_background_marks_unseen() {
let mut state = app_with_workspaces(&["active", "background"]);
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
state.active = Some(0);
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
@ -3872,7 +4000,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Idle,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -3880,6 +4007,10 @@ mod tests {
let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap();
assert!(!pane.seen);
assert!(matches!(
state.toast.as_ref().map(|toast| toast.kind),
Some(ToastKind::Finished)
));
}
#[test]
@ -3902,7 +4033,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Idle,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -3925,7 +4055,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Idle,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -3935,6 +4064,36 @@ mod tests {
assert!(pane.seen);
}
#[test]
fn idle_after_known_unknown_agent_in_background_marks_done() {
let mut state = app_with_workspaces(&["active", "background"]);
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
state.active = Some(0);
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
state.handle_app_event(AppEvent::StateChanged {
pane_id: bg_pane_id,
agent: Some(Agent::Pi),
state: AgentState::Unknown,
visible_blocker: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
});
state.handle_app_event(AppEvent::StateChanged {
pane_id: bg_pane_id,
agent: Some(Agent::Pi),
state: AgentState::Idle,
visible_blocker: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
});
let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap();
assert!(!pane.seen);
}
#[test]
fn waiting_sound_plays_even_in_active_workspace() {
assert_eq!(
@ -3971,7 +4130,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Blocked,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -3996,7 +4154,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Blocked,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4029,7 +4186,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Blocked,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4041,7 +4197,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Working,
visible_blocker: false,
visible_idle: false,
visible_working: true,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4065,7 +4220,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Blocked,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4091,7 +4245,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Blocked,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4119,7 +4272,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Blocked,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4176,7 +4328,6 @@ mod tests {
agent: Some(Agent::Codex),
state: AgentState::Idle,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4196,7 +4347,6 @@ mod tests {
agent: Some(Agent::Codex),
state: AgentState::Blocked,
visible_blocker: true,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4227,7 +4377,6 @@ mod tests {
agent: Some(Agent::Claude),
state: AgentState::Working,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4252,7 +4401,6 @@ mod tests {
agent: Some(Agent::Claude),
state: AgentState::Idle,
visible_blocker: false,
visible_idle: true,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4279,7 +4427,6 @@ mod tests {
agent: Some(Agent::Claude),
state: AgentState::Working,
visible_blocker: false,
visible_idle: false,
visible_working: true,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4298,7 +4445,7 @@ mod tests {
}
#[test]
fn hidden_session_ref_only_update_marks_session_dirty_without_visible_update() {
fn hidden_custom_session_ref_only_update_marks_session_dirty_without_visible_update() {
let mut state = app_with_workspaces(&["active"]);
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
let test_dir = std::env::current_dir().unwrap();
@ -4307,7 +4454,7 @@ mod tests {
let first_updates = state.handle_app_event(AppEvent::HookStateReported {
pane_id,
source: "herdr:pi".into(),
source: "custom:pi".into(),
agent_label: "pi".into(),
state: AgentState::Working,
message: None,
@ -4320,7 +4467,7 @@ mod tests {
let second_updates = state.handle_app_event(AppEvent::HookStateReported {
pane_id,
source: "herdr:pi".into(),
source: "custom:pi".into(),
agent_label: "pi".into(),
state: AgentState::Working,
message: None,
@ -4377,7 +4524,6 @@ mod tests {
agent: Some(Agent::Droid),
state: AgentState::Idle,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4407,7 +4553,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Blocked,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4434,7 +4579,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Blocked,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4458,7 +4602,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Blocked,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4480,7 +4623,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Blocked,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -4677,6 +4819,39 @@ mod tests {
assert_eq!(state.workspaces[0].panes.len(), 1);
}
#[test]
fn pane_process_exit_publish_marks_agent_idle_before_pane_removal() {
let mut state = app_with_workspaces(&["active", "background"]);
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
state.active = Some(1);
state.ensure_test_terminals();
let pane_id = state.workspaces[0].tabs[0].root_pane;
let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap();
state
.terminals
.get_mut(&terminal_id)
.unwrap()
.set_detected_state(Some(Agent::Pi), AgentState::Working);
assert_eq!(
state.terminals.get(&terminal_id).unwrap().state,
AgentState::Working
);
let update = state
.publish_pane_process_exit_if_agent(pane_id)
.expect("process exit update");
assert!(!state.pane_is_in_active_tab(update.ws_idx, pane_id));
assert_eq!(update.previous_state, AgentState::Working);
assert_eq!(update.state, AgentState::Idle);
assert_eq!(update.agent_label.as_deref(), Some("pi"));
assert_eq!(update.known_agent, Some(Agent::Pi));
assert!(matches!(
state.toast.as_ref().map(|toast| toast.kind),
Some(ToastKind::Finished)
));
}
#[test]
fn close_pane_removes_unattached_terminal_state() {
let mut state = app_with_workspaces(&["test"]);

View File

@ -67,6 +67,12 @@ impl App {
}
if let AppEvent::PaneDied { pane_id } = &ev {
let previous_toast = self.state.toast.clone();
if let Some(update) = self.state.publish_pane_process_exit_if_agent(*pane_id) {
self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast);
self.emit_pane_state_update(&update);
self.emit_terminal_or_system_agent_notifications(std::slice::from_ref(&update));
}
if self.runtime_exit_action(*pane_id) == RuntimeExitAction::RespawnShell
&& self.respawn_shell_for_launch_pane(*pane_id)
{
@ -161,57 +167,7 @@ impl App {
let instruction = crate::update::update_install_instruction(&install_command);
let _ = notify(&format!("v{version} available"), Some(&instruction));
} else if self.state.toast_config.delay_seconds == 0 {
for update in &pane_updates {
let is_active_tab = self
.state
.pane_is_in_active_tab(update.ws_idx, update.pane_id);
let suppress_active_tab_notifications =
crate::app::actions::active_tab_suppresses_notifications(
is_active_tab,
self.state.outer_terminal_focus,
);
let Some(kind) = crate::app::actions::notification_toast_for_state_change(
suppress_active_tab_notifications,
update.previous_state,
update.state,
) else {
continue;
};
let Some(ws) = self.state.workspaces.get(update.ws_idx) else {
continue;
};
let Some(pane) = ws
.tabs
.iter()
.find_map(|tab| tab.panes.get(&update.pane_id))
else {
continue;
};
let Some(agent_label) = self
.state
.terminals
.get(&pane.attached_terminal_id)
.and_then(|terminal| terminal.effective_agent_label())
else {
continue;
};
let event_text = match kind {
ToastKind::NeedsAttention => "needs attention",
ToastKind::Finished => "finished",
ToastKind::UpdateInstalled => "updated",
};
let workspace_label =
ws.display_name_from(&self.state.terminals, &self.terminal_runtimes);
let _ = notify(
&format!("{} {}", agent_label, event_text),
Some(&crate::app::actions::notification_context(
ws,
&workspace_label,
update.ws_idx,
update.pane_id,
)),
);
}
self.emit_terminal_or_system_agent_notifications(&pane_updates);
}
}
@ -407,6 +363,78 @@ impl App {
}
}
fn emit_terminal_or_system_agent_notifications(
&self,
pane_updates: &[crate::app::actions::PaneStateUpdate],
) {
if !self.local_terminal_notifications
|| self.state.toast_config.delay_seconds != 0
|| !matches!(
self.state.toast_config.delivery,
crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System
)
{
return;
}
let notify = match self.state.toast_config.delivery {
crate::config::ToastDelivery::Terminal => crate::terminal_notify::show_notification,
crate::config::ToastDelivery::System => crate::platform::show_desktop_notification,
_ => return,
};
for update in pane_updates {
let is_active_tab = self
.state
.pane_is_in_active_tab(update.ws_idx, update.pane_id);
let suppress_active_tab_notifications =
crate::app::actions::active_tab_suppresses_notifications(
is_active_tab,
self.state.outer_terminal_focus,
);
let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update(
suppress_active_tab_notifications,
update,
) else {
continue;
};
let Some(ws) = self.state.workspaces.get(update.ws_idx) else {
continue;
};
let Some(pane) = ws
.tabs
.iter()
.find_map(|tab| tab.panes.get(&update.pane_id))
else {
continue;
};
let Some(agent_label) = self
.state
.terminals
.get(&pane.attached_terminal_id)
.and_then(|terminal| terminal.effective_agent_label())
else {
continue;
};
let event_text = match kind {
ToastKind::NeedsAttention => "needs attention",
ToastKind::Finished => "finished",
ToastKind::UpdateInstalled => "updated",
};
let workspace_label =
ws.display_name_from(&self.state.terminals, &self.terminal_runtimes);
let _ = notify(
&format!("{} {}", agent_label, event_text),
Some(&crate::app::actions::notification_context(
ws,
&workspace_label,
update.ws_idx,
update.pane_id,
)),
);
}
}
pub(crate) fn sync_toast_deadline(
&mut self,
previous_toast: Option<crate::app::state::ToastNotification>,
@ -891,7 +919,6 @@ mod tests {
agent: Some(Agent::Codex),
state: AgentState::Working,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -901,7 +928,6 @@ mod tests {
agent: Some(Agent::Codex),
state: AgentState::Idle,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -984,7 +1010,6 @@ mod tests {
agent: Some(Agent::Codex),
state: AgentState::Working,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -994,7 +1019,6 @@ mod tests {
agent: Some(Agent::Codex),
state: AgentState::Idle,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -1096,7 +1120,6 @@ mod tests {
agent: Some(Agent::Codex),
state: AgentState::Working,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -1117,7 +1140,6 @@ mod tests {
agent: Some(Agent::Codex),
state: AgentState::Idle,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),

View File

@ -2260,7 +2260,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Idle,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),

View File

@ -3621,7 +3621,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Working,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
@ -3646,7 +3645,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Idle,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),

View File

@ -17,6 +17,19 @@ use super::super::AgentState;
pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
if has_visible_blocker(content) {
return AgentState::Blocked;
}
if lower.contains("esc to cancel") {
return AgentState::Working;
}
AgentState::Idle
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
let lower = content.to_lowercase();
let has_waiting_for_approval = lower.contains("waiting for approval");
let has_approval_header = lower.contains("invoke tool")
|| lower.contains("run this command?")
@ -29,13 +42,5 @@ pub(super) fn detect(content: &str) -> AgentState {
|| lower.contains("allow file for every session")
|| lower.contains("deny with feedback"));
if has_approval_actions && (has_waiting_for_approval || has_approval_header) {
return AgentState::Blocked;
}
if lower.contains("esc to cancel") {
return AgentState::Working;
}
AgentState::Idle
has_approval_actions && (has_waiting_for_approval || has_approval_header)
}

View File

@ -1,12 +1,7 @@
use super::super::AgentState;
pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
let has_permission_request = lower.contains("requesting permission for:");
let has_permission_question = lower.contains("do you want to proceed?");
let has_permission_controls = lower.contains("tab amend") && lower.contains("edit command");
if has_permission_request && (has_permission_question || has_permission_controls) {
if has_visible_blocker(content) {
return AgentState::Blocked;
}
@ -17,6 +12,14 @@ pub(super) fn detect(content: &str) -> AgentState {
AgentState::Idle
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
let lower = content.to_lowercase();
let has_permission_request = lower.contains("requesting permission for:");
let has_permission_question = lower.contains("do you want to proceed?");
let has_permission_controls = lower.contains("tab amend") && lower.contains("edit command");
has_permission_request && (has_permission_question || has_permission_controls)
}
fn has_antigravity_spinner(content: &str) -> bool {
content.lines().any(|line| {
let trimmed = line.trim_start();

View File

@ -27,6 +27,10 @@ pub(super) fn detect(content: &str) -> AgentState {
return AgentState::Blocked;
}
if has_dynamic_workflow_prompt(&lower) {
return AgentState::Blocked;
}
if has_working_chrome(content) {
return AgentState::Working;
}
@ -45,6 +49,7 @@ pub(super) fn detect(content: &str) -> AgentState {
pub(super) fn has_visible_blocker(content: &str) -> bool {
let lower = content.to_lowercase();
has_live_blocked_form(content)
|| has_dynamic_workflow_prompt(&lower)
|| lower.contains("do you want to proceed?")
&& has_claude_yes_no_choice(content)
&& (lower.contains("bash command")
@ -96,6 +101,7 @@ fn has_claude_blocked_prompt(content: &str, lower_content: &str) -> bool {
has_confirmation_prompt(lower_content)
|| lower_content.contains("do you want to proceed?")
|| lower_content.contains("would you like to proceed?")
|| has_dynamic_workflow_prompt(lower_content)
|| lower_content.contains("waiting for permission")
|| lower_content.contains("do you want to allow this connection?")
|| lower_content.contains("tab to amend")
@ -105,6 +111,10 @@ fn has_claude_blocked_prompt(content: &str, lower_content: &str) -> bool {
|| (has_selection_prompt(content) && has_claude_yes_no_choice(content))
}
fn has_dynamic_workflow_prompt(lower_content: &str) -> bool {
lower_content.contains("run a dynamic workflow?") && lower_content.contains("esc to cancel")
}
fn has_live_blocked_form(content: &str) -> bool {
let region = content_after_last_horizontal_rule(content);
region.lines().any(|line| {

View File

@ -4,11 +4,7 @@ pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
// Blocked
if lower.contains("let cline use this tool") {
return AgentState::Blocked;
}
// [act mode] or [plan mode] followed by "yes"
if (lower.contains("[act mode]") || lower.contains("[plan mode]")) && lower.contains("yes") {
if has_visible_blocker(content) {
return AgentState::Blocked;
}
@ -20,3 +16,11 @@ pub(super) fn detect(content: &str) -> AgentState {
// Cline defaults to working (unlike most agents that default to idle)
AgentState::Working
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
let lower = content.to_lowercase();
lower.contains("let cline use this tool")
|| ((lower.contains("[act mode]") || lower.contains("[plan mode]"))
&& (lower.contains("execute command?") || lower.contains("use this tool?"))
&& lower.contains("yes"))
}

View File

@ -38,10 +38,6 @@ pub(super) fn has_visible_blocker(content: &str) -> bool {
has_codex_strong_blocked_prompt(content)
}
pub(super) fn has_prompt(content: &str) -> bool {
has_codex_current_prompt(content) || content.lines().any(codex_prompt_line)
}
pub(super) fn has_visible_working(content: &str) -> bool {
has_codex_live_working_at_current_prompt(content)
|| (!has_codex_current_prompt(content) && has_codex_visible_working_without_prompt(content))

View File

@ -4,7 +4,7 @@ pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
// Blocked
if has_cursor_blocked_prompt(content, &lower) {
if has_visible_blocker(content) {
return AgentState::Blocked;
}
@ -19,8 +19,12 @@ pub(super) fn detect(content: &str) -> AgentState {
AgentState::Idle
}
fn has_cursor_blocked_prompt(content: &str, lower: &str) -> bool {
if lower.contains("waiting for approval") || lower.contains("run this command?") {
pub(super) fn has_visible_blocker(content: &str) -> bool {
let lower = content.to_lowercase();
if lower.contains("waiting for approval")
&& lower.contains("run this command?")
&& (lower.contains("run (once) (y)") || lower.contains("skip (esc or n)"))
{
return true;
}

View File

@ -9,19 +9,7 @@ use super::super::{has_braille_spinner, AgentState};
pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
// Blocked: EXECUTE approval prompt with selection UI chrome
// Primary (AND): structural keyword + chrome text = certain
let has_execute = content.contains("EXECUTE");
let has_selection_chrome = lower.contains("enter to select")
|| lower.contains("↑↓ to navigate")
|| lower.contains("esc to cancel");
let has_selection_options = lower.contains("> yes, allow") || lower.contains("> no, cancel");
if has_execute && (has_selection_chrome || has_selection_options) {
return AgentState::Blocked;
}
// Secondary: selection chrome + options together (no EXECUTE needed)
if has_selection_chrome && has_selection_options {
if has_visible_blocker(content) {
return AgentState::Blocked;
}
@ -37,3 +25,16 @@ pub(super) fn detect(content: &str) -> AgentState {
AgentState::Idle
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
let lower = content.to_lowercase();
// Primary (AND): structural keyword + chrome text = certain
let has_execute = content.contains("EXECUTE");
let has_selection_chrome = lower.contains("enter to select")
|| lower.contains("↑↓ to navigate")
|| lower.contains("esc to cancel");
let has_selection_options = lower.contains("> yes, allow") || lower.contains("> no, cancel");
(has_execute && (has_selection_chrome || has_selection_options))
|| (has_selection_chrome && has_selection_options)
}

View File

@ -1,24 +1,11 @@
use super::super::{has_confirmation_prompt, AgentState};
use super::super::AgentState;
pub(super) fn detect(content: &str) -> AgentState {
if has_visible_blocker(content) {
return AgentState::Blocked;
}
let lower = content.to_lowercase();
// Blocked — explicit confirmation
if lower.contains("waiting for user confirmation") {
return AgentState::Blocked;
}
// Blocked — box-drawing confirmation prompts
if content.contains("│ Apply this change")
|| content.contains("│ Allow execution")
|| content.contains("│ Do you want to proceed")
{
return AgentState::Blocked;
}
if has_confirmation_prompt(&lower) {
return AgentState::Blocked;
}
// Working
if lower.contains("esc to cancel") {
return AgentState::Working;
@ -26,3 +13,19 @@ pub(super) fn detect(content: &str) -> AgentState {
AgentState::Idle
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
let lower = content.to_lowercase();
let has_choice = lower.contains("yes") || lower.contains("no");
content.contains("│ Apply this change")
|| content.contains("│ Allow execution")
|| (has_choice
&& (lower.contains("waiting for user confirmation")
|| content.contains("│ Do you want to proceed")
|| lower.contains("do you want to proceed?")))
|| content.lines().any(|line| {
let line = line.trim().to_ascii_lowercase();
line.starts_with("") && (line.contains("yes") || line.contains("allow"))
})
}

View File

@ -4,11 +4,7 @@ pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
// Blocked
if lower.contains("esc to cancel")
&& (lower.contains("enter to select")
|| lower.contains("enter to confirm")
|| lower.contains("enter to submit"))
{
if has_visible_blocker(content) {
return AgentState::Blocked;
}
@ -22,3 +18,11 @@ pub(super) fn detect(content: &str) -> AgentState {
AgentState::Idle
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
let lower = content.to_lowercase();
lower.contains("esc to cancel")
&& (lower.contains("enter to select")
|| lower.contains("enter to confirm")
|| lower.contains("enter to submit"))
}

View File

@ -7,17 +7,11 @@ use super::super::{has_braille_spinner, AgentState};
/// status line such as "⠋ Waiting… 1.8s" plus live controls like
/// "Ctrl+c:cancel" and "Ctrl+Enter:interject".
pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
if lower.contains("use ← → to choose permission whitelist scope")
|| lower.contains("yes, proceed")
|| lower.contains("no, reject")
|| lower.contains("ctrl+o:yolo")
|| lower.contains(":scope")
{
if has_visible_blocker(content) {
return AgentState::Blocked;
}
let lower = content.to_lowercase();
if has_braille_spinner(content)
&& (lower.contains("waiting")
|| lower.contains("run ")
@ -34,3 +28,10 @@ pub(super) fn detect(content: &str) -> AgentState {
AgentState::Idle
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
let lower = content.to_lowercase();
let has_scope_selector = lower.contains("use ← → to choose permission whitelist scope")
|| lower.contains("←/→:scope");
has_scope_selector && lower.contains("yes, proceed") && lower.contains("no, reject")
}

View File

@ -6,21 +6,25 @@ use super::super::AgentState;
/// dialogs for dangerous terminal commands. Prefer the modal controls for
/// blocked detection, then the live interrupt/status controls for working.
pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
let has_approval_options = lower.contains("allow once")
&& lower.contains("allow for this session")
&& lower.contains("deny");
let has_approval_controls = lower.contains("enter to confirm")
|| lower.contains("↑/↓ to select")
|| lower.contains("show full command");
if (lower.contains("dangerous command") || has_approval_options) && has_approval_controls {
if has_visible_blocker(content) {
return AgentState::Blocked;
}
let lower = content.to_lowercase();
if lower.contains("msg=interrupt") || lower.contains("ctrl+c cancel") {
return AgentState::Working;
}
AgentState::Idle
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
let lower = content.to_lowercase();
let has_approval_options = lower.contains("allow once")
&& lower.contains("allow for this session")
&& lower.contains("deny");
let has_approval_controls = lower.contains("enter to confirm")
|| lower.contains("↑/↓ to select")
|| lower.contains("show full command");
(lower.contains("dangerous command") || has_approval_options) && has_approval_controls
}

View File

@ -7,3 +7,7 @@ pub(super) fn detect(content: &str) -> AgentState {
super::opencode::detect(content)
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
super::opencode::has_visible_blocker(content)
}

View File

@ -16,10 +16,6 @@ pub(super) fn has_visible_blocker(content: &str) -> bool {
has_current_approval_panel(content) || has_question_panel(content)
}
pub(super) fn has_prompt_box(content: &str) -> bool {
has_editor_prompt_box(content) && has_footer_context(content)
}
pub(super) fn has_visible_working(content: &str) -> bool {
has_kimi_working_status(content)
}
@ -99,49 +95,3 @@ fn has_question_panel(content: &str) -> bool {
fn has_numeric_choose_hint(lower_content: &str) -> bool {
lower_content.contains(" choose") && lower_content.contains('1') && lower_content.contains('2')
}
fn has_editor_prompt_box(content: &str) -> bool {
let lines: Vec<&str> = content.lines().collect();
for top_index in 0..lines.len() {
if !is_editor_top_border(lines[top_index]) {
continue;
}
let mut saw_prompt = false;
for line in lines.iter().skip(top_index + 1) {
if is_editor_bottom_border(line) {
if saw_prompt {
return true;
}
break;
}
saw_prompt |= is_editor_prompt_line(line);
}
}
false
}
fn is_editor_top_border(line: &str) -> bool {
let trimmed = line.trim();
((trimmed.starts_with('╭') && trimmed.ends_with('╮'))
|| (trimmed.starts_with('├') && trimmed.ends_with('┤')))
&& trimmed.contains('─')
}
fn is_editor_bottom_border(line: &str) -> bool {
let trimmed = line.trim();
trimmed.starts_with('╰') && trimmed.ends_with('╯') && trimmed.contains('─')
}
fn is_editor_prompt_line(line: &str) -> bool {
let trimmed = line.trim_start();
let inner = trimmed.strip_prefix('│').unwrap_or(trimmed).trim_start();
inner.starts_with('>')
}
fn has_footer_context(content: &str) -> bool {
content.to_lowercase().contains("context: ")
}

View File

@ -7,7 +7,7 @@ use super::super::AgentState;
pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
if has_kiro_blocked_prompt(&lower) {
if has_visible_blocker(content) {
return AgentState::Blocked;
}
@ -20,8 +20,9 @@ pub(super) fn detect(content: &str) -> AgentState {
AgentState::Idle
}
fn has_kiro_blocked_prompt(lower_content: &str) -> bool {
has_tool_approval_prompt(lower_content) || has_subagent_approval_prompt(lower_content)
pub(super) fn has_visible_blocker(content: &str) -> bool {
let lower_content = content.to_lowercase();
has_tool_approval_prompt(&lower_content) || has_subagent_approval_prompt(&lower_content)
}
fn has_tool_approval_prompt(lower_content: &str) -> bool {

View File

@ -45,7 +45,6 @@ pub(super) fn detect(agent: Agent, screen_content: &str) -> AgentDetection {
state,
skip_state_update: true,
visible_blocker: false,
visible_idle: false,
visible_working: false,
};
}
@ -54,7 +53,6 @@ pub(super) fn detect(agent: Agent, screen_content: &str) -> AgentDetection {
state,
skip_state_update: false,
visible_blocker: has_visible_blocker(agent, screen_content, state),
visible_idle: has_visible_idle(agent, screen_content, state),
visible_working: has_visible_working(agent, screen_content, state),
}
}
@ -79,20 +77,20 @@ fn has_visible_blocker(agent: Agent, content: &str, state: AgentState) -> bool {
// is known to be structural and live.
Agent::Claude => claude_code::has_visible_blocker(content),
Agent::Codex => codex::has_visible_blocker(content),
Agent::Gemini => gemini::has_visible_blocker(content),
Agent::Cursor => cursor::has_visible_blocker(content),
Agent::Antigravity => antigravity::has_visible_blocker(content),
Agent::Cline => cline::has_visible_blocker(content),
Agent::OpenCode => opencode::has_visible_blocker(content),
Agent::GithubCopilot => github_copilot::has_visible_blocker(content),
Agent::Kimi => kimi::has_visible_blocker(content),
_ => false,
}
}
fn has_visible_idle(agent: Agent, content: &str, state: AgentState) -> bool {
if state != AgentState::Idle {
return false;
}
match agent {
Agent::Claude => claude_code::has_prompt_box(content),
Agent::Codex => codex::has_prompt(content),
Agent::Kimi => kimi::has_prompt_box(content),
Agent::Kiro => kiro::has_visible_blocker(content),
Agent::Droid => droid::has_visible_blocker(content),
Agent::Amp => amp::has_visible_blocker(content),
Agent::Grok => grok::has_visible_blocker(content),
Agent::Hermes => hermes::has_visible_blocker(content),
Agent::Kilo => kilo::has_visible_blocker(content),
Agent::Qodercli => qodercli::has_visible_blocker(content),
_ => false,
}
}

View File

@ -2,7 +2,7 @@ use super::super::{has_interrupt_pattern, AgentState};
pub(super) fn detect(content: &str) -> AgentState {
// Blocked
if content.contains("△ Permission required") || has_opencode_question_prompt(content) {
if has_visible_blocker(content) {
return AgentState::Blocked;
}
@ -17,6 +17,10 @@ pub(super) fn detect(content: &str) -> AgentState {
AgentState::Idle
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
content.contains("△ Permission required") || has_opencode_question_prompt(content)
}
fn has_opencode_question_prompt(content: &str) -> bool {
let lower = content.to_lowercase();
let has_enter_action = lower.contains("enter confirm")

View File

@ -15,7 +15,7 @@ pub(super) fn detect(content: &str) -> AgentState {
return AgentState::Idle;
}
if has_qodercli_blocked_prompt(&lower) {
if has_visible_blocker(content) {
return AgentState::Blocked;
}
@ -87,9 +87,15 @@ fn has_qodercli_spinner_row(content: &str) -> bool {
/// defensive fallbacks in case the title row scrolls off-screen.
/// * The interactive shell waiting hint emitted by qodercli when an agent
/// spawns a shell that is now parked for user keystrokes.
fn has_qodercli_blocked_prompt(lower_content: &str) -> bool {
lower_content.contains("waiting for user confirmation")
|| lower_content.contains("awaiting approval")
pub(super) fn has_visible_blocker(content: &str) -> bool {
let lower_content = content.to_lowercase();
(lower_content.contains("waiting for user confirmation")
&& (lower_content.contains("yes")
|| lower_content.contains("no")
|| lower_content.contains("allow")
|| lower_content.contains("reject")))
|| (lower_content.contains("awaiting approval")
&& (lower_content.contains("allow") || lower_content.contains("reject")))
|| lower_content.contains("permission required")
|| lower_content.contains("allow once or always?")
|| lower_content.contains("asking user")

View File

@ -29,13 +29,9 @@ pub struct AgentDetection {
/// human input. This is stronger than arbitrary prompt-like text in the
/// scrollback and may override a non-blocked integration state.
pub visible_blocker: bool,
/// True when the current screen visibly shows the agent's idle input UI.
/// This lets Herdr recover from integrations that miss an interrupt/stop
/// event without treating an empty or ambiguous screen as idle authority.
pub visible_idle: bool,
/// True when the current screen visibly shows live working chrome. This is
/// narrower than a fallback `Working` heuristic and may guard against stale
/// hook idle reports.
/// True when the current screen visibly shows live working chrome. PTY
/// activity is the normal working authority; this remains diagnostic
/// metadata and for non-PTY fallback paths.
pub visible_working: bool,
}
@ -178,7 +174,6 @@ pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetectio
state: AgentState::Unknown,
skip_state_update: false,
visible_blocker: false,
visible_idle: false,
visible_working: false,
};
};
@ -1135,7 +1130,6 @@ mod tests {
let detection = detect_agent(Some(Agent::Claude), screen);
assert!(detection.skip_state_update);
assert!(!detection.visible_idle);
assert!(!detection.visible_working);
assert!(!detection.visible_blocker);
}
@ -1162,7 +1156,6 @@ mod tests {
let detection = detect_agent(Some(Agent::Claude), screen);
assert!(!detection.skip_state_update);
assert!(detection.visible_idle);
}
#[test]
@ -1196,7 +1189,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Blocked);
assert!(detection.visible_blocker);
assert!(!detection.visible_idle);
}
#[test]
@ -1215,6 +1207,25 @@ mod tests {
assert_eq!(detect_claude(screen), AgentState::Blocked);
}
#[test]
fn claude_dynamic_workflow_prompt_is_visible_blocker() {
let screen = "● Workflow(Review the current worktree diff and verify the resulting findings)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n Run a dynamic workflow?\n\n Review the current worktree diff and verify the resulting findings\n\n This dynamic workflow will spin up multiple subagents across the following phases:\n 1. Overview — map the diff and identify coupled subsystems\n 2. Review — review each changed file in parallel\n 3. Verify — adversarially verify each finding\n 4. Synthesize — summarize confirmed issues and residual risk\n\n Dynamic workflows can use a lot of tokens quickly by running many subagents in parallel — which counts against your usage limit. Stop a running workflow at any time with\n /workflows, or disable dynamic workflows in /config.\n\n 1. Yes, run it\n 2. View raw script\n 3. No\n\n Esc to cancel · Tab to amend\n ctrl+g to edit script in $EDITOR";
let detection = detect_agent(Some(Agent::Claude), screen);
assert_eq!(detection.state, AgentState::Blocked);
assert!(detection.visible_blocker);
assert!(!detection.visible_working);
}
#[test]
fn claude_dynamic_workflow_prompt_uses_stable_modal_gate() {
let screen = "● Workflow(Evaluate changes)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n Run a dynamic workflow?\n\n 1. Scope\n 2. Review\n 3. Synthesize\n\n Esc to cancel · Tab to amend";
let detection = detect_agent(Some(Agent::Claude), screen);
assert_eq!(detection.state, AgentState::Blocked);
assert!(detection.visible_blocker);
}
#[test]
fn claude_question_form_selected_top_is_visible_blocker() {
let screen = " ask again\n─────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Subject ☐ Tone ✔ Submit →\n\nWhat should I ask you about?\n\n 1. Today\n Your current plan or priority.\n 2. Project\n A codebase, feature, bug, or PR.\n 3. Preference\n How you want me to work with you.\n 4. Random\n A casual question with no work context.\n 5. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n 6. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel";
@ -1222,7 +1233,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Blocked);
assert!(detection.visible_blocker);
assert!(!detection.visible_idle);
}
#[test]
@ -1232,7 +1242,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Blocked);
assert!(detection.visible_blocker);
assert!(!detection.visible_idle);
}
#[test]
@ -1242,7 +1251,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Blocked);
assert!(detection.visible_blocker);
assert!(!detection.visible_idle);
}
#[test]
@ -1264,31 +1272,28 @@ mod tests {
}
#[test]
fn claude_prompt_box_is_visible_idle() {
fn claude_prompt_box_does_not_set_screen_idle_signal() {
let screen = "Interrupted.\n─────────────\n \n─────────────";
let detection = detect_agent(Some(Agent::Claude), screen);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
}
#[test]
fn claude_prompt_box_with_status_text_and_custom_status_is_visible_idle() {
fn claude_prompt_box_with_status_text_and_custom_status_does_not_set_screen_idle_signal() {
let screen = "──────────────────────────────────────────── ◐ medium · /effort\n \n────────────────────────────────────────────\n thommie-backend | refactor/cleanup-codebase | Opus 4.8 (1M context)\nIppy Tippy\n/coach-dive to chat about this\n▸▸ auto mode on (shift+tab to cycle) · ← for agents";
let detection = detect_agent(Some(Agent::Claude), screen);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.visible_blocker);
}
#[test]
fn claude_interrupted_permission_prompt_box_is_visible_idle() {
fn claude_interrupted_permission_prompt_box_does_not_set_screen_idle_signal() {
let screen = " this is a test, create some dummy files on /tmp and -rm rf them i wanna test\n permissions\n\n Thought for 7s (ctrl+o to expand)\n\n● Bash(tmpdir=$(mktemp -d /tmp/claude-perm-test.XXXXXX) && touch \"$tmpdir/file1.txt\"\n \"$tmpdir/file2.log\" && mkdir \"$tmpdir/subdir\" && touch\n \"$tmpdir/subdir/nested.txt\"…)\n ⎿ Interrupted · What should Claude do instead?\n\n─────────────────────────────────────────────────────────────────────────────────────────\n \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
let detection = detect_agent(Some(Agent::Claude), screen);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.visible_working);
assert!(!detection.visible_blocker);
}
@ -1299,7 +1304,6 @@ mod tests {
let detection = detect_agent(Some(Agent::Claude), screen);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.visible_working);
assert!(!detection.visible_blocker);
}
@ -1310,7 +1314,6 @@ mod tests {
let detection = detect_agent(Some(Agent::Claude), screen);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.visible_working);
assert!(!detection.visible_blocker);
}
@ -1322,17 +1325,15 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
assert!(!detection.visible_blocker);
}
#[test]
fn claude_separators_without_prompt_are_not_visible_idle() {
fn claude_separators_without_prompt_do_not_set_screen_idle_signal() {
let screen = "Task complete.\n─────────────\nplain text\n─────────────";
let detection = detect_agent(Some(Agent::Claude), screen);
assert_eq!(detection.state, AgentState::Idle);
assert!(!detection.visible_idle);
}
#[test]
@ -1342,7 +1343,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
}
#[test]
@ -1352,7 +1352,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
assert!(!detection.visible_blocker);
}
@ -1363,7 +1362,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
}
#[test]
@ -1372,7 +1370,6 @@ mod tests {
let detection = detect_agent(Some(Agent::Claude), screen);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.visible_working);
assert!(!detection.visible_blocker);
}
@ -1383,7 +1380,6 @@ mod tests {
let detection = detect_agent(Some(Agent::Claude), screen);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.visible_working);
assert!(!detection.visible_blocker);
}
@ -1394,7 +1390,6 @@ mod tests {
let detection = detect_agent(Some(Agent::Claude), screen);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.visible_working);
}
@ -1404,7 +1399,6 @@ mod tests {
let detection = detect_agent(Some(Agent::Claude), screen);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.visible_working);
}
@ -1467,7 +1461,6 @@ mod tests {
let detection = detect_agent(Some(Agent::Codex), screen);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.visible_blocker);
}
@ -1477,7 +1470,6 @@ mod tests {
let detection = detect_agent(Some(Agent::Codex), screen);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.visible_blocker);
}
@ -1493,11 +1485,11 @@ mod tests {
}
#[test]
fn non_codex_blocked_heuristics_are_not_strong_visible_blockers_by_default() {
fn gemini_confirmation_prompt_is_visible_blocker() {
let detection = detect_agent(Some(Agent::Gemini), "Do you want to proceed?\n\nYes No");
assert_eq!(detection.state, AgentState::Blocked);
assert!(!detection.visible_blocker);
assert!(detection.visible_blocker);
}
#[test]
@ -1517,16 +1509,14 @@ mod tests {
assert_eq!(detection.state, AgentState::Blocked);
assert!(detection.visible_blocker);
assert!(!detection.visible_idle);
}
#[test]
fn codex_interrupted_prompt_is_visible_idle() {
fn codex_interrupted_prompt_does_not_set_screen_idle_signal() {
let screen = "■ Conversation interrupted - tell the model what to do differently. Something went\nwrong? Hit `/feedback` to report the issue.\n\n\n Run /review on my current changes\n\n gpt-5.5 high · ~/Projects/herdr-worktrees/issue-249-state-arbitration";
let detection = detect_agent(Some(Agent::Codex), screen);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
}
#[test]
@ -1551,7 +1541,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
}
#[test]
@ -1563,7 +1552,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
}
#[test]
@ -1575,7 +1563,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
}
#[test]
@ -1587,7 +1574,7 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
assert!(!detection.visible_blocker);
}
#[test]
@ -1606,7 +1593,7 @@ mod tests {
);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.visible_blocker);
assert!(!detection.visible_working);
}
@ -1619,7 +1606,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
}
#[test]
@ -1631,7 +1617,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
}
#[test]
@ -1643,7 +1628,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
}
#[test]
@ -1655,7 +1639,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
}
#[test]
@ -1667,7 +1650,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
}
#[test]
@ -1678,7 +1660,6 @@ mod tests {
);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.visible_working);
}
@ -1690,7 +1671,6 @@ mod tests {
);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.visible_working);
}
@ -1703,7 +1683,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
}
#[test]
@ -1726,7 +1705,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Idle);
assert!(!detection.visible_working);
assert!(detection.visible_idle);
}
#[test]
@ -1738,7 +1716,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Idle);
assert!(!detection.visible_working);
assert!(detection.visible_idle);
}
#[test]
@ -1755,7 +1732,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
}
}
@ -1770,7 +1746,6 @@ mod tests {
let detection = detect_agent(Some(Agent::Codex), screen);
assert!(detection.skip_state_update);
assert!(!detection.visible_idle);
assert!(!detection.visible_working);
assert!(!detection.visible_blocker);
}
@ -1781,7 +1756,6 @@ mod tests {
let detection = detect_agent(Some(Agent::Codex), screen);
assert!(detection.skip_state_update);
assert!(!detection.visible_idle);
}
#[test]
@ -1790,7 +1764,6 @@ mod tests {
let detection = detect_agent(Some(Agent::Codex), screen);
assert!(detection.skip_state_update);
assert!(!detection.visible_idle);
}
#[test]
@ -1799,7 +1772,6 @@ mod tests {
let detection = detect_agent(Some(Agent::Codex), screen);
assert!(!detection.skip_state_update);
assert!(detection.visible_idle);
}
#[test]
@ -1815,11 +1787,19 @@ mod tests {
#[test]
fn gemini_waiting_confirmation() {
assert_eq!(
detect_gemini("waiting for user confirmation"),
detect_gemini("waiting for user confirmation\nYes No"),
AgentState::Blocked
);
}
#[test]
fn gemini_waiting_text_without_choices_is_idle() {
assert_eq!(
detect_gemini("waiting for user confirmation"),
AgentState::Idle
);
}
#[test]
fn gemini_waiting_apply() {
assert_eq!(
@ -1892,6 +1872,14 @@ mod tests {
assert_eq!(detect_cursor(screen), AgentState::Blocked);
}
#[test]
fn cursor_approval_words_without_visible_controls_are_idle() {
assert_eq!(
detect_cursor("previous output: waiting for approval"),
AgentState::Idle
);
}
#[test]
fn cursor_running_text_with_unrelated_yes_is_not_blocked() {
let screen = "previous answer mentioned (y)\n⠠⠜ Running 5.52k tokens";
@ -1991,6 +1979,14 @@ mod tests {
);
}
#[test]
fn cline_mode_text_with_unrelated_yes_is_not_blocked() {
assert_eq!(
detect_cline("[act mode]\nPrevious answer was yes."),
AgentState::Working
);
}
#[test]
fn cline_idle_ready() {
assert_eq!(
@ -2322,7 +2318,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Blocked);
assert!(detection.visible_blocker);
assert!(!detection.visible_idle);
}
#[test]
@ -2332,7 +2327,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Blocked);
assert!(detection.visible_blocker);
assert!(!detection.visible_idle);
}
#[test]
@ -2363,7 +2357,6 @@ mod tests {
assert_eq!(detection.state, AgentState::Working);
assert!(detection.visible_working);
assert!(!detection.visible_idle);
}
#[test]
@ -2397,12 +2390,11 @@ mod tests {
}
#[test]
fn kimi_current_editor_box_is_visible_idle() {
fn kimi_current_editor_box_does_not_set_screen_idle_signal() {
let screen = "╭──────────────────────────────────────────────────╮\n│ > │\n╰──────────────────────────────────────────────────╯\nk2 ~/Projects/herdr /help: show commands\n context: 0.0%";
let detection = detect_agent(Some(Agent::Kimi), screen);
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.visible_working);
assert!(!detection.visible_blocker);
}
@ -2587,10 +2579,16 @@ mod tests {
#[test]
fn grok_blocked_wins_over_spinner() {
let screen = "⠹ Run git 30s\nYes, proceed\nNo, reject (type to add feedback)";
let screen = "⠹ Run git 30s\nUse ← → to choose permission whitelist scope\nYes, proceed\nNo, reject (type to add feedback)";
assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked);
}
#[test]
fn grok_choice_words_without_scope_selector_are_not_blocked() {
let screen = "Yes, proceed\nNo, reject (type to add feedback)";
assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Idle);
}
#[test]
fn grok_working_on_waiting_spinner() {
let screen = "⠋ Waiting… 1.8s\nCtrl+c:cancel │ Ctrl+Enter:interject";
@ -2666,11 +2664,19 @@ mod tests {
#[test]
fn qodercli_blocked_on_confirmation() {
assert_eq!(
detect_qodercli("Waiting for user confirmation..."),
detect_qodercli("Waiting for user confirmation...\nAllow\nReject"),
AgentState::Blocked,
);
}
#[test]
fn qodercli_waiting_confirmation_without_choices_is_idle() {
assert_eq!(
detect_qodercli("Waiting for user confirmation..."),
AgentState::Idle,
);
}
#[test]
fn qodercli_working_on_spinner() {
assert_eq!(detect_qodercli("\u{280B} Thinking..."), AgentState::Working);

View File

@ -33,7 +33,6 @@ pub enum AppEvent {
agent: Option<Agent>,
state: AgentState,
visible_blocker: bool,
visible_idle: bool,
visible_working: bool,
process_exited: bool,
observed_at: Instant,

View File

@ -42,6 +42,7 @@ fn pop_keyboard_enhancement_flags() -> io::Result<()> {
Ok(())
}
mod agent_detection_policy;
mod agent_resume;
mod api;
mod app;

View File

@ -2,7 +2,7 @@ use std::cell::Cell;
use std::io;
use std::path::Path;
use std::sync::{
atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering},
atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicU64, Ordering},
Arc, Mutex,
};
@ -81,7 +81,6 @@ async fn publish_state_changed_event(
agent: Option<Agent>,
state: AgentState,
visible_blocker: bool,
visible_idle: bool,
visible_working: bool,
process_exited: bool,
observed_at: std::time::Instant,
@ -95,7 +94,6 @@ async fn publish_state_changed_event(
agent,
state,
visible_blocker,
visible_idle,
visible_working,
process_exited,
observed_at,
@ -120,6 +118,9 @@ const PROCESS_ACQUISITION_FAST_RECHECK: std::time::Duration = std::time::Duratio
const PROCESS_ACQUISITION_SLOW_RECHECK: std::time::Duration = std::time::Duration::from_secs(2);
const PROCESS_ACQUISITION_IDLE_RESET: std::time::Duration = std::time::Duration::from_secs(2);
const STABLE_VISIBLE_SIGNAL_REFRESH: std::time::Duration = std::time::Duration::from_millis(800);
const AGENT_PTY_ACTIVITY_WINDOW: std::time::Duration = std::time::Duration::from_secs(1);
const AGENT_INPUT_TAINT_WINDOW: std::time::Duration = std::time::Duration::from_millis(1200);
const AGENT_STARTUP_GRACE_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
#[derive(Debug, Clone, Copy)]
struct AgentDetectionPresence {
@ -328,7 +329,6 @@ fn probe_foreground_process(pid: u32, foreground_pgid: Option<u32>) -> ProcessPr
struct DetectionPublishState {
state: AgentState,
visible_blocker: bool,
visible_idle: bool,
visible_working: bool,
}
@ -341,14 +341,10 @@ fn should_publish_detection_update(
) -> bool {
next.state != previous.state
|| next.visible_blocker != previous.visible_blocker
|| next.visible_idle != previous.visible_idle
|| next.visible_working != previous.visible_working
|| agent_changed
|| process_exited
|| (stable_visible_signal_refresh_due
&& ((next.visible_blocker && previous.visible_blocker)
|| (next.visible_idle && previous.visible_idle)
|| (next.visible_working && previous.visible_working)))
|| (stable_visible_signal_refresh_due && next.visible_blocker && previous.visible_blocker)
}
fn stable_visible_signal_refresh_due(
@ -357,11 +353,7 @@ fn stable_visible_signal_refresh_due(
last_refresh: Option<std::time::Instant>,
now: std::time::Instant,
) -> bool {
// TODO: Evaluate expanding agent-specific visible_* predicates so more
// detectors can use screen-authoritative refreshes safely.
let stable_visible_signal = (next.visible_blocker && previous.visible_blocker)
|| (next.visible_idle && previous.visible_idle)
|| (next.visible_working && previous.visible_working);
let stable_visible_signal = next.visible_blocker && previous.visible_blocker;
stable_visible_signal
&& last_refresh.is_none_or(|last_refresh| {
@ -383,7 +375,6 @@ fn detection_update_for_publish(
state: AgentState::Idle,
skip_state_update: false,
visible_blocker: false,
visible_idle: false,
visible_working: false,
});
}
@ -392,11 +383,71 @@ fn detection_update_for_publish(
(!detection.skip_state_update).then_some(detection)
}
#[derive(Debug, Default)]
struct PtyCausalityTracker {
last_pty_render_seq: u64,
last_input_seq: u64,
input_tainted_until: Option<std::time::Instant>,
last_agent_pty_at: Option<std::time::Instant>,
}
#[derive(Debug, Clone, Copy)]
struct PtyActivitySignal {
active: bool,
tainted: bool,
}
fn baseline_pty_causality(tracker: &mut PtyCausalityTracker, pty_render_seq: u64, input_seq: u64) {
tracker.last_pty_render_seq = pty_render_seq;
tracker.last_input_seq = input_seq;
tracker.input_tainted_until = None;
tracker.last_agent_pty_at = None;
}
fn agent_caused_pty_activity_active(
pty_render_seq: u64,
input_seq: u64,
tracker: &mut PtyCausalityTracker,
now: std::time::Instant,
) -> PtyActivitySignal {
if input_seq != tracker.last_input_seq {
tracker.last_input_seq = input_seq;
tracker.input_tainted_until = Some(now + AGENT_INPUT_TAINT_WINDOW);
tracker.last_agent_pty_at = None;
}
let tainted = tracker.input_tainted_until.is_some_and(|until| now < until);
if pty_render_seq != tracker.last_pty_render_seq {
tracker.last_pty_render_seq = pty_render_seq;
if !tainted {
tracker.last_agent_pty_at = Some(now);
}
}
if tainted {
return PtyActivitySignal {
active: false,
tainted: true,
};
}
let active = tracker
.last_agent_pty_at
.is_some_and(|last| now.duration_since(last) < AGENT_PTY_ACTIVITY_WINDOW);
PtyActivitySignal {
active,
tainted: false,
}
}
#[cfg(unix)]
fn spawn_basic_detection_task(
pane_id: PaneId,
child_pid: Arc<AtomicU32>,
terminal: Arc<PaneTerminal>,
pty_render_seq: Arc<AtomicU64>,
input_write_seq: Arc<AtomicU64>,
state_events: mpsc::Sender<AppEvent>,
) -> (
tokio::task::AbortHandle,
@ -412,17 +463,19 @@ fn spawn_basic_detection_task(
let mut agent_presence = AgentDetectionPresence::from_agent(None);
let mut state = AgentState::Unknown;
let mut last_visible_blocker = false;
let mut last_visible_idle = false;
let mut last_visible_working = false;
let mut last_visible_signal_refresh = None;
let mut last_screen_working_at = None;
let mut last_process_check = std::time::Instant::now();
let mut last_foreground_pgid = None;
let mut has_process_probe = false;
let mut acquisition_started_at = None;
let mut last_content_change_at = None;
let mut pending_foreground_shell_clear = false;
let mut foreground_shell_exit_reported = false;
let mut release_was_active = false;
let mut last_detection_text = String::new();
let mut pty_causality = PtyCausalityTracker::default();
let mut agent_startup_grace_until = None;
loop {
tokio::select! {
@ -431,17 +484,19 @@ fn spawn_basic_detection_task(
agent_presence = AgentDetectionPresence::from_agent(None);
state = AgentState::Unknown;
last_visible_blocker = false;
last_visible_idle = false;
last_visible_working = false;
last_visible_signal_refresh = None;
last_screen_working_at = None;
last_process_check = std::time::Instant::now();
last_foreground_pgid = None;
has_process_probe = false;
acquisition_started_at = None;
last_content_change_at = None;
pending_foreground_shell_clear = false;
foreground_shell_exit_reported = false;
release_was_active = false;
last_detection_text.clear();
pty_causality = PtyCausalityTracker::default();
agent_startup_grace_until = None;
}
}
@ -456,27 +511,11 @@ fn spawn_basic_detection_task(
let pid = child_pid.load(Ordering::Acquire);
let mut agent_changed = false;
let mut agent = agent_presence.current_agent();
let content = terminal.detection_text();
let content_changed = content != last_detection_text;
last_detection_text.clone_from(&content);
if crate::detect::should_skip_state_update(agent, &content) {
continue;
}
let foreground_pgid = (pid > 0)
.then(|| crate::detect::foreground_process_group_id(pid))
.flatten();
let process_group_changed =
foreground_group_changed(foreground_pgid, last_foreground_pgid);
sync_content_change_acquisition(
agent_presence.current_agent(),
suppressed_agent,
process_group_changed,
content_changed,
now,
&mut acquisition_started_at,
&mut last_content_change_at,
);
let should_check_process = pid > 0
&& should_probe_foreground_job(ProcessProbeInput {
current_agent: agent_presence.current_agent(),
@ -486,7 +525,7 @@ fn spawn_basic_detection_task(
has_process_probe,
acquisition_age: acquisition_started_at
.map(|started| now.duration_since(started)),
pending_foreground_shell_clear: false,
pending_foreground_shell_clear,
pending_restore_probe: false,
elapsed_since_process_check: now.duration_since(last_process_check),
});
@ -496,6 +535,8 @@ fn spawn_basic_detection_task(
let had_process_probe = has_process_probe;
has_process_probe = true;
let probe = probe_foreground_process(pid, foreground_pgid);
let process_group_id = probe.process_group_id;
let foreground_is_pane_shell = probe.foreground_is_pane_shell;
let mut new_agent = probe.agent;
if let Some(suppressed_agent) = suppressed_agent {
if new_agent == Some(suppressed_agent) {
@ -504,51 +545,157 @@ fn spawn_basic_detection_task(
*pending_release = None;
}
}
if new_agent.is_none() {
last_foreground_pgid = probe.process_group_id.or(foreground_pgid);
let previous_agent = agent_presence.current_agent();
let changed = match foreground_shell_agent_action(
previous_agent,
new_agent,
foreground_is_pane_shell,
foreground_shell_exit_reported,
) {
ForegroundShellAgentAction::ReportProcessExit => {
pending_foreground_shell_clear = true;
false
}
ForegroundShellAgentAction::ClearAgent => {
pending_foreground_shell_clear = false;
foreground_shell_exit_reported = false;
agent_presence.clear_current_agent()
}
ForegroundShellAgentAction::ObserveProbe => {
pending_foreground_shell_clear = false;
foreground_shell_exit_reported = false;
agent_presence.observe_process_probe(new_agent)
}
};
if new_agent.is_some() {
last_foreground_pgid = process_group_id.or(foreground_pgid);
acquisition_started_at = None;
last_content_change_at = None;
} else if agent_presence.current_agent().is_none() {
last_foreground_pgid = process_group_id.or(foreground_pgid);
if had_process_probe && process_group_changed {
acquisition_started_at = Some(now);
}
} else {
last_foreground_pgid = probe.process_group_id.or(foreground_pgid);
acquisition_started_at = None;
last_content_change_at = None;
last_foreground_pgid = process_group_id.or(foreground_pgid);
}
let previous_agent = agent_presence.current_agent();
if agent_presence.observe_process_probe(new_agent) {
if changed {
agent = agent_presence.current_agent();
agent_changed = previous_agent != agent;
if agent_changed {
last_screen_working_at = None;
if agent.is_some() {
agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW);
baseline_pty_causality(
&mut pty_causality,
pty_render_seq.load(Ordering::Relaxed),
input_write_seq.load(Ordering::Relaxed),
);
state = AgentState::Idle;
last_visible_blocker = false;
last_visible_working = false;
last_visible_signal_refresh = None;
publish_state_changed_event(
state_events.clone(),
pane_id,
agent,
AgentState::Idle,
false,
false,
false,
now,
)
.await;
} else {
agent_startup_grace_until = None;
}
}
}
}
let Some(detection) = detection_update_for_publish(agent, &content, false) else {
let process_exited = pending_foreground_shell_clear
&& agent.is_some()
&& !foreground_shell_exit_reported;
if let Some(until) = agent_startup_grace_until {
if process_exited {
agent_startup_grace_until = None;
} else {
if now < until {
continue;
}
baseline_pty_causality(
&mut pty_causality,
pty_render_seq.load(Ordering::Relaxed),
input_write_seq.load(Ordering::Relaxed),
);
agent_startup_grace_until = None;
continue;
}
}
let content = terminal.detection_text();
let content_changed = content != last_detection_text;
last_detection_text.clone_from(&content);
if !process_exited && crate::detect::should_skip_state_update(agent, &content) {
continue;
}
sync_content_change_acquisition(
agent_presence.current_agent(),
suppressed_agent,
process_group_changed,
content_changed,
now,
&mut acquisition_started_at,
&mut last_content_change_at,
);
let Some(screen_detection) =
detection_update_for_publish(agent, &content, process_exited)
else {
continue;
};
let new_state = crate::terminal::state::stabilize_agent_detection(
agent,
state,
detection,
false,
now,
&mut last_screen_working_at,
);
let pty_signal = if agent.is_some() {
let signal = agent_caused_pty_activity_active(
pty_render_seq.load(Ordering::Relaxed),
input_write_seq.load(Ordering::Relaxed),
&mut pty_causality,
now,
);
Some(crate::agent_detection_policy::PtySignal {
active: signal.active,
tainted: signal.tainted,
})
} else {
None
};
let detection = match crate::agent_detection_policy::apply_detection_policy(
crate::agent_detection_policy::DetectionPolicyInput {
agent,
screen_detection,
process_exited,
startup_grace_active: false,
pty_signal,
},
) {
crate::agent_detection_policy::DetectionPolicyDecision::Publish(detection) => {
detection
}
crate::agent_detection_policy::DetectionPolicyDecision::Freeze => {
continue;
}
};
let new_state = crate::terminal::state::stabilize_agent_detection(detection);
let visible_blocker = detection.visible_blocker && new_state == AgentState::Blocked;
let visible_idle = detection.visible_idle && new_state == AgentState::Idle;
let visible_working = detection.visible_working && new_state == AgentState::Working;
let previous_publish = DetectionPublishState {
state,
visible_blocker: last_visible_blocker,
visible_idle: last_visible_idle,
visible_working: last_visible_working,
};
let next_publish = DetectionPublishState {
state: new_state,
visible_blocker,
visible_idle,
visible_working,
};
let stable_refresh_due = stable_visible_signal_refresh_due(
@ -562,14 +709,13 @@ fn spawn_basic_detection_task(
previous_publish,
next_publish,
agent_changed,
false,
process_exited,
stable_refresh_due,
) {
state = new_state;
last_visible_blocker = visible_blocker;
last_visible_idle = visible_idle;
last_visible_working = visible_working;
if visible_blocker || visible_idle || visible_working {
if visible_blocker || visible_working {
last_visible_signal_refresh = Some(now);
} else {
last_visible_signal_refresh = None;
@ -580,12 +726,14 @@ fn spawn_basic_detection_task(
agent,
new_state,
visible_blocker,
visible_idle,
visible_working,
false,
process_exited,
now,
)
.await;
if process_exited {
foreground_shell_exit_reported = true;
}
}
}
});
@ -657,6 +805,7 @@ pub struct PaneRuntime {
reported_cwd: Arc<Mutex<Option<std::path::PathBuf>>>,
child_wait_completed: Option<Arc<AtomicBool>>,
kitty_keyboard_flags: Arc<AtomicU16>,
input_write_seq: Arc<AtomicU64>,
detect_reset_notify: Arc<Notify>,
pending_release: Arc<Mutex<Option<PendingAgentRelease>>>,
preserve_processes_on_drop: bool,
@ -1399,12 +1548,15 @@ impl PaneRuntime {
let child_pid = Arc::new(AtomicU32::new(child_pid));
let reported_cwd = Arc::new(Mutex::new(None));
let kitty_keyboard_flags = Arc::new(AtomicU16::new(keyboard_protocol_flags));
let input_write_seq = Arc::new(AtomicU64::new(0));
let pty_render_seq = Arc::new(AtomicU64::new(0));
let io = {
let terminal = terminal.clone();
let response_writer = response_tx.clone();
let render_notify = render_notify.clone();
let render_dirty = render_dirty.clone();
let pty_render_seq = pty_render_seq.clone();
let child_pid = child_pid.clone();
let read_events = events.clone();
let reported_cwd = reported_cwd.clone();
@ -1414,8 +1566,11 @@ impl PaneRuntime {
let shell_pid = child_pid.load(Ordering::Acquire);
let result =
terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer);
if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) {
render_notify.notify_one();
if result.request_render {
pty_render_seq.fetch_add(1, Ordering::Relaxed);
if !render_dirty.swap(true, Ordering::AcqRel) {
render_notify.notify_one();
}
}
if let Some(delay) = result.render_delay {
let render_notify = render_notify.clone();
@ -1457,8 +1612,14 @@ impl PaneRuntime {
})?)
};
let (detect_handle, detect_reset_notify, pending_release) =
spawn_basic_detection_task(pane_id, child_pid.clone(), terminal.clone(), events);
let (detect_handle, detect_reset_notify, pending_release) = spawn_basic_detection_task(
pane_id,
child_pid.clone(),
terminal.clone(),
pty_render_seq,
input_write_seq.clone(),
events,
);
Ok(Self {
pane_id,
@ -1469,6 +1630,7 @@ impl PaneRuntime {
reported_cwd,
child_wait_completed: None,
kitty_keyboard_flags,
input_write_seq,
detect_reset_notify,
pending_release,
preserve_processes_on_drop: true,
@ -1517,6 +1679,8 @@ impl PaneRuntime {
let child_pid = Arc::new(AtomicU32::new(0));
let reported_cwd = Arc::new(Mutex::new(None));
let child_wait_completed = Arc::new(AtomicBool::new(false));
let input_write_seq = Arc::new(AtomicU64::new(0));
let pty_render_seq = Arc::new(AtomicU64::new(0));
{
let child_pid = child_pid.clone();
let child_wait_completed = child_wait_completed.clone();
@ -1548,6 +1712,7 @@ impl PaneRuntime {
let response_writer = response_tx.clone();
let render_notify = render_notify.clone();
let render_dirty = render_dirty.clone();
let pty_render_seq = pty_render_seq.clone();
let child_pid = child_pid.clone();
let events = events.clone();
let reported_cwd = reported_cwd.clone();
@ -1556,8 +1721,11 @@ impl PaneRuntime {
let shell_pid = child_pid.load(Ordering::Acquire);
let result =
terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer);
if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) {
render_notify.notify_one();
if result.request_render {
pty_render_seq.fetch_add(1, Ordering::Relaxed);
if !render_dirty.swap(true, Ordering::AcqRel) {
render_notify.notify_one();
}
}
if let Some(delay) = result.render_delay {
let render_notify = render_notify.clone();
@ -1609,6 +1777,8 @@ impl PaneRuntime {
let child_pid = child_pid.clone();
let terminal = terminal.clone();
let state_events = events.clone();
let pty_render_seq = pty_render_seq.clone();
let input_write_seq_for_task = input_write_seq.clone();
let render_notify = render_notify.clone();
let render_dirty = render_dirty.clone();
let detect_reset_notify = Arc::new(Notify::new());
@ -1619,11 +1789,7 @@ impl PaneRuntime {
let handle = tokio::spawn(async move {
let mut agent_presence =
AgentDetectionPresence::from_agent(initial_state.detected_agent);
let mut state = if initial_state.detected_agent.is_some() {
AgentState::Idle
} else {
AgentState::Unknown
};
let mut state = AgentState::Idle;
let mut last_process_check = Instant::now();
let mut last_foreground_pgid = None;
let mut has_process_probe = false;
@ -1633,12 +1799,12 @@ impl PaneRuntime {
let mut foreground_shell_exit_reported = false;
let mut release_was_active = false;
let mut pending_restore_probe = initial_state.detected_agent.is_some();
let mut last_screen_working_at = None;
let mut last_visible_blocker = false;
let mut last_visible_idle = false;
let mut last_visible_working = false;
let mut last_visible_signal_refresh = None;
let mut last_detection_text = String::new();
let mut pty_causality = PtyCausalityTracker::default();
let mut agent_startup_grace_until = None;
tokio::time::sleep(Duration::from_millis(50)).await;
@ -1666,12 +1832,12 @@ impl PaneRuntime {
foreground_shell_exit_reported = false;
release_was_active = false;
pending_restore_probe = false;
last_screen_working_at = None;
last_visible_blocker = false;
last_visible_idle = false;
last_visible_working = false;
last_visible_signal_refresh = None;
last_detection_text.clear();
pty_causality = PtyCausalityTracker::default();
agent_startup_grace_until = None;
}
}
@ -1684,26 +1850,11 @@ impl PaneRuntime {
}
release_was_active = suppressed_agent.is_some();
let pid = child_pid.load(Ordering::Acquire);
let content = terminal.detection_text();
let content_changed = content != last_detection_text;
last_detection_text.clone_from(&content);
if detect::should_skip_state_update(agent_presence.current_agent(), &content) {
continue;
}
let foreground_pgid = (pid > 0)
.then(|| detect::foreground_process_group_id(pid))
.flatten();
let process_group_changed =
foreground_group_changed(foreground_pgid, last_foreground_pgid);
sync_content_change_acquisition(
agent_presence.current_agent(),
suppressed_agent,
process_group_changed,
content_changed,
now,
&mut acquisition_started_at,
&mut last_content_change_at,
);
let should_check_process = pid > 0
&& should_probe_foreground_job(ProcessProbeInput {
current_agent: agent_presence.current_agent(),
@ -1780,7 +1931,32 @@ impl PaneRuntime {
if changed {
agent = agent_presence.current_agent();
if agent != previous_agent {
last_screen_working_at = None;
if agent.is_some() {
agent_startup_grace_until =
Some(now + AGENT_STARTUP_GRACE_WINDOW);
baseline_pty_causality(
&mut pty_causality,
pty_render_seq.load(Ordering::Relaxed),
input_write_seq_for_task.load(Ordering::Relaxed),
);
state = AgentState::Idle;
last_visible_blocker = false;
last_visible_working = false;
last_visible_signal_refresh = None;
publish_state_changed_event(
state_events.clone(),
pane_id,
agent,
AgentState::Idle,
false,
false,
false,
now,
)
.await;
} else {
agent_startup_grace_until = None;
}
}
if let Some(process_name) = process_name {
info!(
@ -1817,36 +1993,89 @@ impl PaneRuntime {
let process_exited = pending_foreground_shell_clear
&& agent.is_some()
&& !foreground_shell_exit_reported;
let Some(detection) =
if let Some(until) = agent_startup_grace_until {
if process_exited {
agent_startup_grace_until = None;
} else {
if now < until {
continue;
}
baseline_pty_causality(
&mut pty_causality,
pty_render_seq.load(Ordering::Relaxed),
input_write_seq_for_task.load(Ordering::Relaxed),
);
agent_startup_grace_until = None;
continue;
}
}
let content = terminal.detection_text();
let content_changed = content != last_detection_text;
last_detection_text.clone_from(&content);
if detect::should_skip_state_update(agent, &content) {
continue;
}
sync_content_change_acquisition(
agent_presence.current_agent(),
suppressed_agent,
process_group_changed,
content_changed,
now,
&mut acquisition_started_at,
&mut last_content_change_at,
);
let Some(screen_detection) =
detection_update_for_publish(agent, &content, process_exited)
else {
continue;
};
let raw_state = detection.state;
let new_state = crate::terminal::state::stabilize_agent_detection(
agent,
state,
detection,
process_exited,
now,
&mut last_screen_working_at,
);
let pty_signal = if agent.is_some() {
let signal = agent_caused_pty_activity_active(
pty_render_seq.load(Ordering::Relaxed),
input_write_seq_for_task.load(Ordering::Relaxed),
&mut pty_causality,
now,
);
Some(crate::agent_detection_policy::PtySignal {
active: signal.active,
tainted: signal.tainted,
})
} else {
None
};
let detection = match crate::agent_detection_policy::apply_detection_policy(
crate::agent_detection_policy::DetectionPolicyInput {
agent,
screen_detection,
process_exited,
startup_grace_active: false,
pty_signal,
},
) {
crate::agent_detection_policy::DetectionPolicyDecision::Publish(
detection,
) => detection,
crate::agent_detection_policy::DetectionPolicyDecision::Freeze => {
continue;
}
};
let new_state = crate::terminal::state::stabilize_agent_detection(detection);
let visible_blocker =
detection.visible_blocker && new_state == AgentState::Blocked;
let visible_idle = detection.visible_idle && new_state == AgentState::Idle;
let visible_working =
detection.visible_working && new_state == AgentState::Working;
let previous_publish = DetectionPublishState {
state,
visible_blocker: last_visible_blocker,
visible_idle: last_visible_idle,
visible_working: last_visible_working,
};
let next_publish = DetectionPublishState {
state: new_state,
visible_blocker,
visible_idle,
visible_working,
};
let stable_refresh_due = stable_visible_signal_refresh_due(
@ -1863,19 +2092,10 @@ impl PaneRuntime {
process_exited,
stable_refresh_due,
) {
debug!(
pane = pane_id.raw(),
?state,
?raw_state,
?new_state,
?agent,
"state changed"
);
state = new_state;
last_visible_blocker = visible_blocker;
last_visible_idle = visible_idle;
last_visible_working = visible_working;
if visible_blocker || visible_idle || visible_working {
if visible_blocker || visible_working {
last_visible_signal_refresh = Some(now);
} else {
last_visible_signal_refresh = None;
@ -1886,7 +2106,6 @@ impl PaneRuntime {
agent,
new_state,
visible_blocker,
visible_idle,
visible_working,
process_exited,
now,
@ -1910,6 +2129,7 @@ impl PaneRuntime {
reported_cwd,
child_wait_completed: Some(child_wait_completed),
kitty_keyboard_flags,
input_write_seq,
detect_reset_notify,
pending_release,
preserve_processes_on_drop: false,
@ -1940,6 +2160,7 @@ impl PaneRuntime {
if self.current_size.get() == size {
return;
}
self.input_write_seq.fetch_add(1, Ordering::Relaxed);
self.current_size.set(size);
let terminal_responses = self
.terminal
@ -1955,6 +2176,7 @@ impl PaneRuntime {
#[cfg(unix)]
pub fn nudge_child_redraw_after_handoff(&self) {
self.input_write_seq.fetch_add(1, Ordering::Relaxed);
let (rows, cols, cell_width_px, cell_height_px) = self.current_size.get();
self.io
.nudge_child_redraw_after_handoff(rows, cols, cell_width_px, cell_height_px);
@ -2081,11 +2303,19 @@ impl PaneRuntime {
}
pub async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError<Bytes>> {
self.io.send_bytes(bytes).await
let result = self.io.send_bytes(bytes).await;
if result.is_ok() {
self.input_write_seq.fetch_add(1, Ordering::Relaxed);
}
result
}
pub fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError<Bytes>> {
self.io.try_send_bytes(bytes)
let result = self.io.try_send_bytes(bytes);
if result.is_ok() {
self.input_write_seq.fetch_add(1, Ordering::Relaxed);
}
result
}
pub async fn send_paste(&self, text: String) -> Result<(), mpsc::error::SendError<Bytes>> {
@ -2283,6 +2513,7 @@ impl PaneRuntime {
reported_cwd: Arc::new(Mutex::new(None)),
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
input_write_seq: Arc::new(AtomicU64::new(0)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
preserve_processes_on_drop: true,
@ -2655,6 +2886,7 @@ mod tests {
reported_cwd: Arc::new(Mutex::new(None)),
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
input_write_seq: Arc::new(AtomicU64::new(0)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
preserve_processes_on_drop: true,
@ -2684,6 +2916,7 @@ mod tests {
reported_cwd: Arc::new(Mutex::new(None)),
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
input_write_seq: Arc::new(AtomicU64::new(0)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
preserve_processes_on_drop: true,
@ -2743,38 +2976,12 @@ mod tests {
assert!(!detection.skip_state_update);
}
#[test]
fn stable_visible_idle_republishes_for_stale_hook_deadline() {
let now = std::time::Instant::now();
let previous = DetectionPublishState {
state: AgentState::Idle,
visible_blocker: false,
visible_idle: true,
visible_working: false,
};
let refresh_due = stable_visible_signal_refresh_due(
previous,
previous,
Some(now - STABLE_VISIBLE_SIGNAL_REFRESH),
now,
);
assert!(should_publish_detection_update(
previous,
previous,
false,
false,
refresh_due
));
}
#[test]
fn stable_plain_idle_does_not_republish() {
let now = std::time::Instant::now();
let previous = DetectionPublishState {
state: AgentState::Idle,
visible_blocker: false,
visible_idle: false,
visible_working: false,
};
let refresh_due = stable_visible_signal_refresh_due(
@ -2794,12 +3001,11 @@ mod tests {
}
#[test]
fn stable_visible_working_republishes_for_hook_override_refresh() {
fn stable_visible_working_does_not_republish() {
let now = std::time::Instant::now();
let previous = DetectionPublishState {
state: AgentState::Working,
visible_blocker: false,
visible_idle: false,
visible_working: true,
};
let refresh_due = stable_visible_signal_refresh_due(
@ -2809,7 +3015,7 @@ mod tests {
now,
);
assert!(should_publish_detection_update(
assert!(!should_publish_detection_update(
previous,
previous,
false,
@ -2824,7 +3030,6 @@ mod tests {
let previous = DetectionPublishState {
state: AgentState::Blocked,
visible_blocker: true,
visible_idle: false,
visible_working: false,
};
let refresh_due = stable_visible_signal_refresh_due(
@ -3220,6 +3425,81 @@ mod tests {
assert_eq!(presence.current_agent(), None);
}
#[test]
fn pty_activity_outside_taint_reports_active_until_hold_expires() {
let now = std::time::Instant::now();
let mut tracker = PtyCausalityTracker::default();
baseline_pty_causality(&mut tracker, 1, 1);
let active = agent_caused_pty_activity_active(2, 1, &mut tracker, now);
assert!(active.active);
assert!(!active.tainted);
let held = agent_caused_pty_activity_active(
2,
1,
&mut tracker,
now + AGENT_PTY_ACTIVITY_WINDOW - std::time::Duration::from_millis(1),
);
assert!(held.active);
assert!(!held.tainted);
let expired = agent_caused_pty_activity_active(
2,
1,
&mut tracker,
now + AGENT_PTY_ACTIVITY_WINDOW + std::time::Duration::from_millis(1),
);
assert!(!expired.active);
assert!(!expired.tainted);
}
#[test]
fn input_taint_discards_pty_activity_until_fresh_post_taint_render() {
let now = std::time::Instant::now();
let mut tracker = PtyCausalityTracker::default();
baseline_pty_causality(&mut tracker, 1, 1);
let tainted = agent_caused_pty_activity_active(2, 2, &mut tracker, now);
assert!(!tainted.active);
assert!(tainted.tainted);
let after_taint = agent_caused_pty_activity_active(
2,
2,
&mut tracker,
now + AGENT_INPUT_TAINT_WINDOW + std::time::Duration::from_millis(1),
);
assert!(!after_taint.active);
assert!(!after_taint.tainted);
let fresh_render = agent_caused_pty_activity_active(
3,
2,
&mut tracker,
now + AGENT_INPUT_TAINT_WINDOW + std::time::Duration::from_millis(2),
);
assert!(fresh_render.active);
assert!(!fresh_render.tainted);
}
#[test]
fn startup_grace_rebaseline_discards_accumulated_pty_activity() {
let now = std::time::Instant::now();
let mut tracker = PtyCausalityTracker::default();
baseline_pty_causality(&mut tracker, 1, 1);
let startup_render = agent_caused_pty_activity_active(2, 1, &mut tracker, now);
assert!(startup_render.active);
baseline_pty_causality(&mut tracker, 2, 1);
let after_grace =
agent_caused_pty_activity_active(2, 1, &mut tracker, now + AGENT_STARTUP_GRACE_WINDOW);
assert!(!after_grace.active);
assert!(!after_grace.tainted);
}
#[tokio::test]
async fn state_changed_event_waits_for_queue_space_instead_of_dropping() {
let (tx, mut rx) = mpsc::channel(1);
@ -3239,7 +3519,6 @@ mod tests {
false,
false,
false,
false,
std::time::Instant::now(),
);
tokio::pin!(publish);
@ -3276,7 +3555,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Idle,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: _,

View File

@ -1395,6 +1395,89 @@ impl HeadlessServer {
.unwrap_or(crate::detect::AgentState::Unknown)
}
fn pane_effective_agent_label(&self, pane_id: crate::layout::PaneId) -> Option<String> {
self.app.state.workspaces.iter().find_map(|ws| {
ws.tabs.iter().find_map(|tab| {
let pane = tab.panes.get(&pane_id)?;
self.app
.state
.terminals
.get(&pane.attached_terminal_id)
.and_then(|terminal| terminal.effective_agent_label())
.map(str::to_string)
})
})
}
fn forward_pane_state_update_notifications_to_clients(
&mut self,
update: &crate::app::actions::PaneStateUpdate,
) {
if self.app.state.toast_config.delay_seconds != 0 {
return;
}
let is_active_tab = self
.app
.state
.pane_is_in_active_tab(update.ws_idx, update.pane_id);
let suppress_active_tab_notifications =
self.active_tab_suppresses_notifications(is_active_tab);
if 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,
update.previous_state,
update.state,
update.previous_agent_label.as_deref(),
update.agent_label.as_deref(),
)
{
self.send_notify_to_foreground_client(
protocol::NotifyKind::Sound,
sound_notify_message(sound),
None,
);
}
}
if !should_forward_toast_to_clients(self.app.state.toast_config.delivery) {
return;
}
let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update(
suppress_active_tab_notifications,
update,
) else {
return;
};
let Some(ws) = self.app.state.workspaces.get(update.ws_idx) else {
return;
};
let Some(agent_label) = update.agent_label.as_deref() else {
return;
};
let event_text = match kind {
crate::app::state::ToastKind::NeedsAttention => "needs attention",
crate::app::state::ToastKind::Finished => "finished",
crate::app::state::ToastKind::UpdateInstalled => "updated",
};
let workspace_label =
ws.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes);
let context = crate::app::actions::notification_context(
ws,
&workspace_label,
update.ws_idx,
update.pane_id,
);
self.send_notify_to_foreground_client(
toast_notify_kind(self.app.state.toast_config.delivery)
.expect("toast forwarding requires a client notification kind"),
format!("{agent_label} {event_text}"),
non_empty_body(&context),
);
}
fn forward_agent_notification_delivery(
&mut self,
delivery: &crate::app::state::AgentNotificationDelivery,
@ -1561,6 +1644,7 @@ impl HeadlessServer {
// is processed. Notifications must follow effective state changes,
// not raw fallback reports that may be masked by hook authority.
let prev_state = self.pane_effective_state(pane_id_val);
let prev_agent_label = self.pane_effective_agent_label(pane_id_val);
// Handle the state change (updates pane state, sets toast on AppState).
// Headless mode disables local sound playback separately from the
@ -1583,15 +1667,20 @@ impl HeadlessServer {
self.active_tab_suppresses_notifications(is_active_tab);
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
&& self.app.state.sound.allows(agent_val)
{
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
suppress_active_tab_notifications,
prev_state,
next_state,
) {
if let Some(sound) =
crate::app::actions::notification_sound_for_state_change_with_agent_labels(
suppress_active_tab_notifications,
prev_state,
next_state,
prev_agent_label.as_deref(),
next_agent_label.as_deref(),
)
{
self.send_notify_to_foreground_client(
protocol::NotifyKind::Sound,
sound_notify_message(sound),
@ -1617,6 +1706,7 @@ impl HeadlessServer {
suppress_active_tab_notifications,
prev_state,
next_state,
prev_agent_label.as_deref(),
)
}
} else {
@ -1648,6 +1738,7 @@ impl HeadlessServer {
// are already folded into pane.state; raw hook transitions must not
// produce a second notification path.
let prev_state = self.pane_effective_state(pane_id_val);
let prev_agent_label = self.pane_effective_agent_label(pane_id_val);
self.sync_foreground_client_state();
self.app.handle_internal_event(ev);
@ -1668,15 +1759,20 @@ impl HeadlessServer {
self.active_tab_suppresses_notifications(is_active_tab);
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
&& self.app.state.sound.allows(agent_val)
{
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
suppress_active_tab_notifications,
prev_state,
next_state,
) {
if let Some(sound) =
crate::app::actions::notification_sound_for_state_change_with_agent_labels(
suppress_active_tab_notifications,
prev_state,
next_state,
prev_agent_label.as_deref(),
next_agent_label.as_deref(),
)
{
self.send_notify_to_foreground_client(
protocol::NotifyKind::Sound,
sound_notify_message(sound),
@ -1702,6 +1798,7 @@ impl HeadlessServer {
suppress_active_tab_notifications,
prev_state,
next_state,
prev_agent_label.as_deref(),
)
}
} else {
@ -1765,6 +1862,14 @@ impl HeadlessServer {
.map(|pane| pane.attached_terminal_id.to_string())
})
});
if let Some(update) = self
.app
.state
.publish_pane_process_exit_if_agent(pane_id_val)
{
self.app.emit_pane_state_update(&update);
self.forward_pane_state_update_notifications_to_clients(&update);
}
self.app.handle_internal_event(ev);
@ -2419,7 +2524,12 @@ impl HeadlessServer {
// bypasses drain_internal_events_with_forwarding. Headless mode disables
// local sound playback, so sound notifications need to be forwarded here.
let toast_before = self.app.state.toast.clone();
let pane_states_before: Vec<(usize, crate::layout::PaneId, crate::detect::AgentState)> = {
let pane_states_before: Vec<(
usize,
crate::layout::PaneId,
crate::detect::AgentState,
Option<String>,
)> = {
let terminals = &self.app.state.terminals;
self.app
.state
@ -2429,9 +2539,14 @@ impl HeadlessServer {
.flat_map(|(ws_idx, ws)| {
ws.tabs.iter().flat_map(move |tab| {
tab.panes.iter().filter_map(move |(&pane_id, pane)| {
terminals
.get(&pane.attached_terminal_id)
.map(|terminal| (ws_idx, pane_id, terminal.state))
terminals.get(&pane.attached_terminal_id).map(|terminal| {
(
ws_idx,
pane_id,
terminal.state,
terminal.effective_agent_label().map(str::to_string),
)
})
})
})
})
@ -2495,7 +2610,7 @@ impl HeadlessServer {
// Forward notifications for effective pane state changes that occurred
// during the API request. Hook authority is already folded into
// pane.state, so raw hook transitions must not produce separate sounds.
for (ws_idx, pane_id, prev_state) in &pane_states_before {
for (ws_idx, pane_id, prev_state, prev_agent_label) in &pane_states_before {
let pane_after = self
.app
.state
@ -2526,6 +2641,7 @@ impl HeadlessServer {
self.active_tab_suppresses_notifications(is_active_tab);
let agent = terminal_after.effective_known_agent();
let agent_label = terminal_after.effective_agent_label().map(str::to_string);
debug!(
ws_idx,
@ -2540,11 +2656,15 @@ impl HeadlessServer {
&& self.app.state.toast_config.delay_seconds == 0
&& should_forward_toast_to_clients(self.app.state.toast_config.delivery)
{
if let Some(kind) = crate::app::actions::notification_toast_for_state_change(
suppress_active_tab_notifications,
*prev_state,
new_state,
) {
if let Some(kind) =
crate::app::actions::notification_toast_for_state_change_with_agent_labels(
suppress_active_tab_notifications,
*prev_state,
new_state,
prev_agent_label.as_deref(),
agent_label.as_deref(),
)
{
if let Some(agent_label) = self
.app
.state
@ -2581,11 +2701,15 @@ impl HeadlessServer {
// Clients still decide locally whether they can execute the side effect.
if self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent)
{
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
suppress_active_tab_notifications,
*prev_state,
new_state,
) {
if let Some(sound) =
crate::app::actions::notification_sound_for_state_change_with_agent_labels(
suppress_active_tab_notifications,
*prev_state,
new_state,
prev_agent_label.as_deref(),
agent_label.as_deref(),
)
{
debug!(sound = ?sound, "forwarding sound notification from API request");
self.send_notify_to_foreground_client(
protocol::NotifyKind::Sound,
@ -7207,7 +7331,6 @@ next_tab = ""
agent: Some(crate::detect::Agent::Pi),
state: crate::detect::AgentState::Blocked,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: Instant::now(),
@ -7297,7 +7420,6 @@ next_tab = ""
agent: Some(crate::detect::Agent::Pi),
state: crate::detect::AgentState::Blocked,
visible_blocker: false,
visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: Instant::now(),

View File

@ -25,13 +25,8 @@ pub(crate) fn toast_message_from_state_change(
suppress_active_tab_notifications: bool,
prev_state: AgentState,
new_state: AgentState,
previous_agent_label: Option<&str>,
) -> Option<String> {
let kind = app::actions::notification_toast_for_state_change(
suppress_active_tab_notifications,
prev_state,
new_state,
)?;
state
.workspaces
.iter()
@ -43,6 +38,13 @@ pub(crate) fn toast_message_from_state_change(
.terminals
.get(&pane.attached_terminal_id)
.and_then(|terminal| terminal.effective_agent_label())?;
let kind = app::actions::notification_toast_for_state_change_with_agent_labels(
suppress_active_tab_notifications,
prev_state,
new_state,
previous_agent_label,
Some(agent_label),
)?;
let workspace_label = ws.display_name_from(&state.terminals, terminal_runtimes);
Some(format!(
"{} {}: {}",
@ -138,6 +140,7 @@ mod tests {
false,
AgentState::Working,
AgentState::Idle,
Some("codex"),
);
assert_eq!(

View File

@ -352,7 +352,7 @@ impl TerminalState {
fn effective_custom_status_for_state_at_with_ttl(
&self,
state: AgentState,
_state: AgentState,
now: Instant,
enforce_ttl: bool,
) -> Option<String> {
@ -360,10 +360,7 @@ impl TerminalState {
return Some(custom_status);
}
if self.visible_blocker_overrides_hook()
|| self.visible_working_overrides_hook()
|| self.visible_idle_masks_hook_custom_status(state, now)
{
if self.visible_blocker_overrides_hook() {
return None;
}

File diff suppressed because it is too large Load Diff

View File

@ -1941,7 +1941,7 @@ fn pane_info_and_subscriptions_expose_done_agent_status() {
fs::write(
&fake_pi,
format!(
"#!/bin/sh\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\nwhile [ ! -f '{}' ]; do sleep 0.05; done\n",
"#!/bin/sh\nprintf 'starting\\n'\nsleep 4\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\nwhile [ ! -f '{}' ]; do sleep 0.05; done\n",
stop_file.display()
),
)
@ -2014,7 +2014,7 @@ fn pane_info_and_subscriptions_expose_done_agent_status() {
);
assert_eq!(send_enter["result"]["type"], "ok");
let status_event = reader.read_json_line(Duration::from_secs(8));
let status_event = reader.read_json_line(Duration::from_secs(12));
assert_eq!(status_event["event"], "pane.agent_status_changed");
assert_eq!(status_event["data"]["pane_id"], background_pane_id);
assert_eq!(status_event["data"]["agent_status"], "done");

View File

@ -2779,7 +2779,7 @@ fn wait_agent_status_exits_when_idle_status_matches() {
let fake_pi = bin_dir.join("pi");
fs::write(
&fake_pi,
"#!/bin/sh\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n",
"#!/bin/sh\nprintf 'starting\\n'\nsleep 4\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n",
)
.unwrap();
#[cfg(unix)]
@ -2822,7 +2822,7 @@ fn wait_agent_status_exits_when_idle_status_matches() {
"--status",
"idle",
"--timeout",
"5000",
"10000",
],
);
assert!(
@ -2907,7 +2907,7 @@ fn wait_agent_status_exits_when_done_status_matches() {
let fake_pi = bin_dir.join("pi");
fs::write(
&fake_pi,
"#!/bin/sh\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n",
"#!/bin/sh\nprintf 'starting\\n'\nsleep 4\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n",
)
.unwrap();
#[cfg(unix)]
@ -2962,7 +2962,7 @@ fn wait_agent_status_exits_when_done_status_matches() {
"--status",
"done",
"--timeout",
"5000",
"10000",
],
);
assert!(