feat: distribute agent detection manifests

This commit is contained in:
Ogulcan Celik 2026-06-10 15:24:24 +03:00
parent 8d13492926
commit 36a1b7f864
75 changed files with 6047 additions and 4392 deletions

View File

@ -9,7 +9,7 @@ Terminal workspace manager for AI coding agents. Rust + ratatui.
- **No god objects.** If a module is doing too many things, split it. `app/` is already split into state, actions, and input. Keep it that way.
- **Platform code is isolated.** OS-specific behavior lives in `src/platform/`. Core modules don't have `#[cfg(target_os)]`.
- **Detection is decoupled.** The detector reads a screen snapshot, never touches the parser or viewport state.
- **Screen detection is evidence-based.** When changing `src/detect/agents/`, first capture the relevant bottom-buffer state with `herdr pane read --source recent --format text` and, when styling or alternate screen behavior matters, `--format ansi`. Decide which visible controls are invariant, which are alternatives, and encode them as explicit AND/OR gates. Do not match whole-pane incidental text, and do not use the user-visible viewport for agent status because users can scroll it.
- **Screen detection is evidence-based.** When changing `src/detect/manifests/`, first capture the relevant bottom-buffer state with `herdr agent read <pane> --source detection --format text` and, when styling or alternate screen behavior matters, `--format ansi`. Decide which visible controls are invariant, which are alternatives, and encode them as explicit AND/OR gates. Do not match whole-pane incidental text, and do not use the user-visible viewport for agent status because users can scroll it.
- **UI patterns should be reused.** Herdr is a mouse-first TUI. New dialogs, onboarding, settings, and post-update flows should follow the existing UI/UX language and interaction patterns instead of inventing one-off screens. Prefer reusing existing modal/screen structure, affordances, and close actions so the app feels consistent.
## Multi-agent isolation
@ -49,6 +49,12 @@ Run `just check` before committing unless Can explicitly accepts narrower valida
Unit tests live next to the code (`#[cfg(test)] mod tests`). New `AppState` or `Workspace` behavior should be testable with `AppState::test_new()` and `Workspace::test_new()` without PTYs.
## Agent Detection Updates
Agent detection changes should use the manifest hot-reload loop. Can drives the real agent UI into the target state, then you read the pane with `herdr agent read <pane> --source detection --format text` and inspect matching with `herdr agent explain <pane> --json`. Update the bundled manifest in `src/detect/manifests/<agent>.toml`, copy that manifest to the local override path at `~/.config/herdr/agent-detection/<agent>.toml`, then run `herdr server reload-agent-manifests`. Can verifies the live pane state, and once the rule is correct, remove the local override so the committed bundled manifest remains the source of truth.
Do not add large agent-specific full-screen fixture suites for routine manifest tuning. Keep Rust tests focused on manifest parsing, rule semantics, skip-state semantics, source precedence, cache reload behavior, and update flow. Use live pane reads for agent-specific screen evidence.
## Vendored libghostty-vt
`vendor/libghostty-vt.vendor.json` records the upstream source commit currently vendored.

View File

@ -3,6 +3,7 @@
## Unreleased
### Fixed
- Agent state detection for non-authoritative agents now comes from screen manifests instead of PTY-first semantic arbitration, so terminal output activity no longer publishes `working`, vetoes visible blockers, or decides idle fallback.
- Numeric keypad keys that send VT100 application-keypad escape sequences now enter their digits and operators instead of being dropped. (#493)
- Codex panes now stay marked working when the live status header uses reasoning-summary text such as `Investigating code output` instead of the literal `Working` label. (#501)
- Native pane URL clicks now use Cmd-click on macOS and Ctrl-click on other platforms.
@ -13,6 +14,8 @@
- Full-screen TUIs such as Neovim now receive resize-generated terminal responses after Herdr internal pane resizes, so grown panes redraw without waiting for extra input. (#471)
### Added
- Added remote auto-updates for agent detection manifests, with per-agent validation, local override precedence, `herdr server agent-manifests` diagnostics, and explain output showing remote manifest status.
- Added `herdr agent explain` to show the manifest source, matched rule, evaluated matcher and region evidence, visible evidence flags, skipped-update reason, and idle fallback reason for live panes or saved screen fixtures.
- Added `herdr integration install droid` for Factory Droid hooks that report session ids through Herdr's socket API. When native agent session restore is enabled, Herdr can resume Droid panes with `droid --resume <id>`.
- Added `herdr integration install kilo` for Kilo Code CLI plugins that report lifecycle state and session ids through Herdr's socket API. When native agent session restore is enabled, Herdr can resume Kilo panes with `kilo --session <id>`.
- Added directional pane swap with `prefix+shift+h/j/k/l`, a pane context-menu swap action, pane layout/neighbor/edge/focus/resize socket APIs, matching CLI commands, and optional `pane split --ratio` support.
@ -21,7 +24,7 @@
- Added native Windows beta documentation, `install.ps1`, preview Windows release assets, update-channel wiring, and platform capability tracking for ConPTY panes, semantic client input, Windows agent discovery, known partial cwd behavior, and unsupported Unix-only features such as live handoff, direct terminal attach, and `herdr --remote` from the Windows binary.
### Changed
- OpenCode installed with the current Herdr plugin now reports lifecycle state directly instead of relying on PTY/screen state detection. Droid, Kimi Code CLI, and Qoder CLI now report native session identity while leaving lifecycle state to PTY/screen detection.
- OpenCode installed with the current Herdr plugin now reports lifecycle state directly instead of relying on screen manifest detection. Droid, Kimi Code CLI, and Qoder CLI now report native session identity while leaving lifecycle state to screen manifest detection.
## [0.6.8] - 2026-06-04
@ -45,7 +48,7 @@ This is a hotfix release for v0.6.7, prioritizing a server-crash fix for panes t
- Added a remote SSH bridge keepalive fallback. `herdr --remote` now generates a temporary SSH config that includes the user's SSH config first, then adds `ServerAliveInterval` and `ServerAliveCountMax` only when the user has not already configured keepalives. Set `[remote].manage_ssh_config = false` to disable this. (#354, #355, thanks @SunskyXH)
- Added `ui.right_click_passthrough_modifier` so a configured modifier such as `ctrl` can forward right-click hold and drag gestures to mouse-reporting pane apps while normal right-click still opens Herdr's pane menu. (#148)
- Added Kilo Code CLI automatic detection for idle, working, and blocked terminal states. (#270)
- Added `herdr integration install copilot` for GitHub Copilot CLI hooks that report native session ids through Herdr's socket API. Copilot state still comes from Herdr's PTY/screen detection because Copilot hooks do not provide complete lifecycle coverage. When native agent session restore is enabled, Herdr can resume Copilot panes with `copilot --resume=<id>`. (#232, #386, thanks @LaneBirmingham)
- Added `herdr integration install copilot` for GitHub Copilot CLI hooks that report native session ids through Herdr's socket API. Copilot state still comes from Herdr's screen detection because Copilot hooks do not provide complete lifecycle coverage. When native agent session restore is enabled, Herdr can resume Copilot panes with `copilot --resume=<id>`. (#232, #386, thanks @LaneBirmingham)
### Changed
- Native agent session restore is now enabled by default for supported panes with current official integrations. Set `[session] resume_agents_on_restore = false` to disable it.

View File

@ -11,22 +11,22 @@ Automatic detection works out of the box for common coding agents. The important
| Agent | State authority | Integration role |
| --- | --- | --- |
| Pi | lifecycle hooks when installed; otherwise PTY/screen | state and session |
| Pi | lifecycle hooks when installed; otherwise screen manifest | state and session |
| OMP | lifecycle hooks when installed | state |
| GitHub Copilot CLI | PTY/screen | session |
| Kimi Code CLI | PTY/screen | session |
| Hermes Agent | lifecycle hooks when installed; otherwise PTY/screen | state and session |
| Qoder CLI | PTY/screen | session |
| Droid | PTY/screen | session |
| OpenCode | lifecycle plugin when installed; otherwise PTY/screen | state and session |
| Kilo Code CLI | lifecycle plugin when installed; otherwise PTY/screen | state and session |
| Claude Code | PTY/screen | session |
| Codex | PTY/screen | session |
| Cursor Agent CLI | PTY/screen | session |
| Amp | PTY/screen | none |
| Grok CLI | PTY/screen | none |
| Antigravity CLI | PTY/screen | none |
| Kiro CLI | PTY/screen | none |
| GitHub Copilot CLI | screen manifest | session |
| Kimi Code CLI | screen manifest | session |
| Hermes Agent | lifecycle hooks when installed; otherwise screen manifest | state and session |
| Qoder CLI | screen manifest | session |
| Droid | screen manifest | session |
| OpenCode | lifecycle plugin when installed; otherwise screen manifest | state and session |
| Kilo Code CLI | lifecycle plugin when installed; otherwise screen manifest | state and session |
| Claude Code | screen manifest | session |
| Codex | screen manifest | session |
| Cursor Agent CLI | screen manifest | session |
| Amp | screen manifest | none |
| Grok CLI | screen manifest | none |
| Antigravity CLI | screen manifest | none |
| Kiro CLI | screen manifest | none |
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.
@ -34,19 +34,44 @@ Detected but less thoroughly tested: Gemini CLI and Cline. Unsupported agents st
Herdr first detects the foreground process in each pane. After that, each pane has one status authority.
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.
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 screen manifest fallback for that same lifecycle authority. This avoids two competing sources of truth.
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`.
For agents without complete lifecycle hooks, Herdr identifies the foreground process and reads the live bottom-buffer screen snapshot. It evaluates bundled TOML manifests against that snapshot to classify `idle`, `working`, and `blocked`. PTY bytes are terminal I/O evidence only; they do not publish `working`, veto `blocked`, or decide `idle`.
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.
The screen snapshot comes from the recent bottom of the pane buffer, not the scrolled viewport. If you scroll back in Herdr, detection still follows the live agent UI at the bottom.
Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, Qoder CLI, and Cursor Agent CLI 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.
Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, Qoder CLI, and Cursor Agent CLI 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 screen manifest 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`.
Blocked detection is deliberately strict for screen-manifest agents. Herdr only marks `blocked` when the live bottom-buffer snapshot matches known visible approval, question, or permission UI. If no manifest rule matches for a known agent, Herdr falls back to `idle` and labels that fallback as `default_known_agent_idle_fallback` in explain output.
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.
This means unusual new agent prompts may initially show as `idle` instead of `blocked` until Herdr learns that screen shape. Those interactions should not make Herdr send input or take destructive action; they only affect the visible status and waits.
## Detection manifests
Bundled manifests live inside Herdr. Herdr also checks herdr.dev for remote manifest updates and applies valid per-agent rule updates automatically without requiring a Herdr restart. Remote manifests are stored in Herdr's state directory.
Local overrides can replace a remote or bundled manifest from the platform config directory:
```text
~/.config/herdr/agent-detection/<agent>.toml
```
Local overrides always win. Without a local override, Herdr uses the newer compatible manifest between the cached remote manifest and the bundled manifest in the running binary. On debug builds, the same config helper may use a development directory such as `herdr-dev`. Invalid override files are ignored with a warning and Herdr falls back to the cached remote or bundled manifest for that agent.
Remote manifests patch detection rules for agents Herdr already knows how to identify. Adding a completely new agent still requires a Herdr binary update for process detection, labels, and integration behavior.
The running server loads active manifests into memory on startup. Automatic remote manifest updates reload that in-memory cache after new rules are written. After editing a local override manually, restart Herdr or run `herdr server reload-agent-manifests` to apply the file to the running server.
Use `herdr agent explain` when a pane shows the wrong state:
```bash
herdr agent explain <target>
herdr agent explain --file screen.txt --agent codex --json
```
Live explain is evaluated by the running server, so it reflects the active manifest cache. The explain output shows the agent, final state, whether screen detection was skipped by a full lifecycle authority, manifest source and version, cached remote version, local override shadowing, remote update status, matched rule, visible evidence flags, matcher and region evidence for evaluated rules, skipped-update reason for transcript viewers, and the idle fallback reason when no rule matched.
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

@ -39,9 +39,11 @@ herdr status client
herdr server
herdr server stop
herdr server reload-config
herdr server agent-manifests [--json]
herdr server reload-agent-manifests
```
`herdr server` runs the headless server explicitly. Use it for supervised or service-style setups. `reload-config` applies reloadable settings without restarting panes.
`herdr server` runs the headless server explicitly. Use it for supervised or service-style setups. `reload-config` applies reloadable settings without restarting panes. `agent-manifests` shows the active agent detection manifest sources, cached remote versions, and last remote update results. `reload-agent-manifests` reloads agent detection manifests into the running server after local override edits.
## Notifications
@ -124,7 +126,7 @@ herdr pane close <pane_id>
Read output:
```bash
herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N]
herdr pane read <pane_id> [--source visible|recent|recent-unwrapped|detection] [--lines N]
herdr pane read <pane_id> --source visible --ansi
herdr pane read <pane_id> --source recent-unwrapped --lines 120
```
@ -180,19 +182,23 @@ herdr pane report-metadata <pane_id> \
```bash
herdr agent list
herdr agent get <target>
herdr agent read <target> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]
herdr agent read <target> [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi]
herdr agent send <target> <text>
herdr agent rename <target> <name>|--clear
herdr agent focus <target>
herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]
herdr agent attach <target> [--takeover]
herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>
herdr agent explain <target> [--json]
herdr agent explain --file PATH --agent LABEL [--json]
```
Agent targets can be terminal IDs, unique agent names, detected or reported agent labels, or legacy pane IDs. Names and labels are agent identities. Terminal IDs and legacy pane IDs are low-level escape hatches.
`agent read` reads the resolved terminal stream. `agent send` writes literal text to that stream. `agent get`, `agent focus`, `agent wait`, and `agent attach` require the resolved terminal to have agent identity. `agent rename` can assign that identity.
`agent explain` asks the running server to classify the same bottom-buffer detection snapshot used by screen detection, so live output reflects the server's active manifest cache. Because this uses the `agent.explain` socket method, restart or hand off to an updated server after upgrading Herdr before using live explain. Use `--file PATH --agent LABEL` to explain a saved fixture locally instead. The output includes whether screen detection was skipped by a full lifecycle authority, the manifest source and version, cached remote version, local override shadowing, remote update status, matched rule, visible evidence flags, matcher and region evidence for evaluated rules, skipped-update reason, and idle fallback reason. Add `--json` for issue reports or tests.
Use `pane send-text`, `pane send-keys`, `pane run`, and `terminal attach` for ordinary terminals, servers, tests, shells, or low-level terminal control. Use `pane run` when you want to submit a command with Enter.
## Direct terminal attach
@ -256,6 +262,7 @@ herdr integration status [--outdated-only]
| `visible` | Current rendered screen. Best for UI feedback loops. |
| `recent` | Recent scrollback with terminal wrapping. |
| `recent-unwrapped` | Recent scrollback without soft wrapping. Best for logs. |
| `detection` | Bottom-buffer snapshot used by agent screen detection. |
## Environment variables

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, PTY/screen activity, and optional integrations.
An agent is a process Herdr recognizes inside a pane. Herdr detects agents from foreground processes, screen manifests, and optional integrations.
Agent states are:

View File

@ -49,8 +49,8 @@ Herdr uses integrations in two different ways:
| Integration type | Agents | Effect |
| --- | --- | --- |
| Lifecycle authority | Pi, OMP, OpenCode, Kilo Code CLI, Hermes Agent | 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, GitHub Copilot CLI, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI | The integration reports native session references for restore. State still comes from Herdr's PTY/screen detection. |
| Lifecycle authority | Pi, OMP, OpenCode, Kilo Code CLI, Hermes Agent | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use screen manifest fallback for that same lifecycle authority. |
| Session identity | Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. |
Custom socket integrations can also report state when they define state that is not visible in the native terminal UI.
@ -100,7 +100,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 PTY/screen detection.
The hook reports Claude Code session identity to the local Herdr socket on session start. Claude Code state comes from Herdr's screen manifest 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.
@ -112,7 +112,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 PTY/screen detection.
The Codex hook reports session identity through the same local socket API used by other integrations. Codex state comes from Herdr's screen manifest 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.
@ -124,7 +124,7 @@ Install the GitHub Copilot CLI hook:
herdr integration install copilot
```
The Copilot hook reports session identity through the same local socket API used by other integrations. Copilot state comes from Herdr's PTY/screen detection.
The Copilot hook reports session identity through the same local socket API used by other integrations. Copilot state comes from Herdr's screen manifest detection.
Herdr uses `~/.copilot` by default, or `COPILOT_HOME` when set. The Copilot config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and updates `settings.json` with a `SessionStart` hook entry. Uninstall removes Herdr entries from `settings.json` and deletes the hook script.
@ -138,7 +138,7 @@ Install the Kimi Code CLI hook:
herdr integration install kimi
```
The hook reports Kimi session identity to Herdr for native restore. Lifecycle state still comes from Herdr's PTY/screen detection because Kimi hooks do not cover every lifecycle transition. It requires Kimi Code CLI `0.8.0` or newer.
The hook reports Kimi session identity to Herdr for native restore. Lifecycle state still comes from Herdr's screen manifest detection because Kimi hooks do not cover every lifecycle transition. It requires Kimi Code CLI `0.8.0` or newer.
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.
@ -152,7 +152,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. Lifecycle state still comes from Herdr's PTY/screen detection because Droid hooks do not cover every lifecycle transition.
The Droid hook reports session identity through the same local socket API used by other integrations. Lifecycle state still comes from Herdr's screen manifest detection because Droid hooks do not cover every lifecycle transition.
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.
@ -168,7 +168,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 lifecycle state and 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>`. Native PTY/screen detection remains available when the plugin is not installed.
The plugin reports lifecycle state and 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>`. Native screen manifest detection remains available when the plugin is not installed.
## Kilo Code CLI
@ -180,7 +180,7 @@ herdr integration install kilo
Herdr writes the plugin to `~/.config/kilo/plugin/herdr-agent-state.js`. The Kilo config directory must already exist. Uninstall removes only that plugin file.
The plugin reports lifecycle state and session identity while Kilo runs inside a Herdr pane. After Kilo emits a session-bearing event, Herdr can use the reported session id to resume the pane with `kilo --session <id>`. Native PTY/screen detection remains available when the plugin is not installed.
The plugin reports lifecycle state and session identity while Kilo runs inside a Herdr pane. After Kilo emits a session-bearing event, Herdr can use the reported session id to resume the pane with `kilo --session <id>`. Native screen manifest detection remains available when the plugin is not installed.
## Hermes Agent
@ -192,7 +192,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 PTY/screen detection remains 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 screen manifest detection remains available when the plugin is not installed.
## Qoder CLI
@ -202,13 +202,13 @@ Install the Qoder CLI hook:
herdr integration install qodercli
```
The hook reports Qoder CLI session identity to Herdr for native restore. Lifecycle state still comes from Herdr's PTY/screen detection because Qoder hooks do not cover every lifecycle transition.
The hook reports Qoder CLI session identity to Herdr for native restore. Lifecycle state still comes from Herdr's screen manifest detection because Qoder hooks do not cover every lifecycle transition.
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.
Herdr resumes stored Qoder CLI sessions with `qodercli --resume <id>`.
Native PTY/screen detection remains available when the hook is not installed.
Native screen manifest detection remains available when the hook is not installed.
## Cursor Agent CLI
@ -218,7 +218,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 PTY/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 screen manifest 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

@ -80,13 +80,13 @@ Raw socket method names use dot notation:
| Area | Methods |
| --- | --- |
| Server | `ping`, `server.stop`, `server.reload_config` |
| Server | `ping`, `server.stop`, `server.reload_config`, `server.agent_manifests`, `server.reload_agent_manifests` |
| Notification | `notification.show` |
| Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.close` |
| Worktree | `worktree.list`, `worktree.create`, `worktree.open`, `worktree.remove` |
| Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.close` |
| Pane | `pane.split`, `pane.swap`, `pane.zoom`, `pane.layout`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` |
| Agent | `agent.list`, `agent.get`, `agent.read`, `agent.send`, `agent.rename`, `agent.focus`, `agent.start` |
| Agent | `agent.list`, `agent.get`, `agent.read`, `agent.explain`, `agent.send`, `agent.rename`, `agent.focus`, `agent.start` |
| Events | `events.subscribe`, `events.wait` |
| Integrations | `integration.install`, `integration.uninstall` |
@ -330,9 +330,11 @@ Use `pane.read` through the CLI unless you are writing a protocol client.
herdr pane read 1-1 --source visible --lines 80
herdr pane read 1-1 --source recent --lines 120
herdr pane read 1-1 --source recent-unwrapped --lines 120
herdr pane read 1-1 --source detection
```
`recent-unwrapped` is useful for logs because it ignores soft wrapping.
`detection` returns the bottom-buffer snapshot used by agent screen detection.
## Waiting for state
@ -367,6 +369,46 @@ Successful responses look like this:
}
```
`server.agent_manifests` returns the active agent detection manifest sources and remote update diagnostics without reloading rules:
```json
{
"id": "req_1",
"result": {
"type": "agent_manifest_status",
"last_check_unix": 1781043522,
"last_result": "checked",
"manifests": [
{
"agent": "cursor",
"source": "/home/me/.config/herdr/agent-detection/cursor.toml",
"source_kind": "local override",
"active_version": "2026.06.10.1",
"cached_remote_version": "2026.06.10.1",
"local_override_shadowing_remote": true,
"remote_update_result": "current"
}
]
}
}
```
Fields such as `last_check_unix`, `last_result`, `active_version`, `cached_remote_version`, `remote_update_result`, `remote_update_error`, `remote_last_checked_unix`, and `warning` are omitted when not available. `server.reload_agent_manifests` returns `agent_manifest_reload` with the same `manifests` item shape after reloading the in-memory rule cache.
`agent.explain` evaluates the target pane's detection snapshot in the running server using the server's active manifest cache:
```json
{
"id": "req_2",
"method": "agent.explain",
"params": { "target": "1-1" }
}
```
The response contains the same explain object printed by `herdr agent explain --json`, including the final state, manifest source and version, matched rule, evaluated rule evidence, skip-state reason, idle fallback reason, and `screen_detection_skip_reason` when a full lifecycle hook authority makes screen rules non-authoritative.
Clients need a running server that supports `agent.explain`; after upgrading Herdr, restart or live-handoff the server before relying on this method.
Errors look like this:
```json

View File

@ -3,7 +3,7 @@
# Run tests
test:
cargo nextest run --locked --status-level fail --final-status-level fail --failure-output final --success-output never
python3 -m unittest scripts.test_changelog scripts.test_preview scripts.test_vendor_libghostty_vt
python3 -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_preview scripts.test_vendor_libghostty_vt
# Run one nextest filter, e.g. `just test-one codex_stale_working`
test-one filter:
@ -20,7 +20,7 @@ ci filter='all()': lint
# Check formatting + run unit tests + maintenance script tests
check: ci
python3 -m unittest scripts.test_changelog scripts.test_preview scripts.test_vendor_libghostty_vt
python3 -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_preview scripts.test_vendor_libghostty_vt
@echo "docs reminder: if this changes user-facing behavior, make sure the relevant release docs are updated or called out before release."
# Install repo-local git hooks
@ -44,6 +44,7 @@ build-libghostty-vt:
# Check that release docs and changelog have been finalized from docs/next before release
release-docs-check:
python3 scripts/agent_detection_manifest_check.py --require-website
@for file in README.md CHANGELOG.md; do \
if ! diff -u "$file" "docs/next/$file"; then \
echo "error: $file differs from docs/next/$file; finalize release docs before releasing"; \

View File

@ -0,0 +1,334 @@
#!/usr/bin/env python3
"""Validate bundled and published agent detection manifests."""
from __future__ import annotations
import argparse
import re
import sys
import tomllib
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_BUNDLED_DIR = PROJECT_ROOT / "src" / "detect" / "manifests"
DEFAULT_WEBSITE_DIR = PROJECT_ROOT / "website" / "agent-detection"
ENGINE_SOURCE = PROJECT_ROOT / "src" / "detect" / "manifest_update.rs"
MANIFEST_KEYS = {"id", "version", "min_engine_version", "updated_at", "aliases", "rules"}
RULE_KEYS = {
"id",
"state",
"priority",
"region",
"visible_idle",
"visible_blocker",
"visible_working",
"skip_state_update",
"all",
"any",
"not",
"contains",
"regex",
"line_regex",
}
GATE_KEYS = {"all", "any", "not", "contains", "regex", "line_regex"}
STATES = {"idle", "working", "blocked", "unknown"}
REGION_RE = re.compile(
r"^(whole_recent|whole_recent_without_current_prompt_marker|after_last_prompt_marker|"
r"before_current_prompt_marker|current_prompt_block_marker|after_current_prompt_block_marker|"
r"prompt_box_body|above_prompt_box|last_non_empty_above_prompt_box|after_last_horizontal_rule|"
r"bottom_lines\([1-9][0-9]*\)|bottom_non_empty_lines\([1-9][0-9]*\))$"
)
VERSION_RE = re.compile(r"^[0-9]+(?:\.[0-9]+)*$")
MAX_RULES_PER_MANIFEST = 128
MAX_GATE_DEPTH = 8
MAX_TOTAL_GATES = 512
MAX_MATCHERS_PER_GATE = 32
MAX_TOTAL_MATCHERS = 1024
MAX_MATCHER_CHARS = 512
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bundled-dir", type=Path, default=DEFAULT_BUNDLED_DIR)
parser.add_argument("--website-dir", type=Path, default=DEFAULT_WEBSITE_DIR)
parser.add_argument("--engine-version", type=int)
parser.add_argument(
"--require-website",
action="store_true",
help="fail if website agent-detection assets or catalog are missing",
)
return parser.parse_args()
def read_engine_version(explicit: int | None) -> int:
if explicit is not None:
return explicit
content = ENGINE_SOURCE.read_text(encoding="utf-8")
match = re.search(r"MANIFEST_ENGINE_VERSION:\s*u32\s*=\s*([0-9]+)", content)
if not match:
raise CheckError(f"could not find MANIFEST_ENGINE_VERSION in {ENGINE_SOURCE}")
return int(match.group(1))
class CheckError(Exception):
pass
def load_toml(path: Path) -> dict:
try:
with path.open("rb") as fh:
value = tomllib.load(fh)
except tomllib.TOMLDecodeError as exc:
raise CheckError(f"{path}: invalid TOML: {exc}") from exc
if not isinstance(value, dict):
raise CheckError(f"{path}: TOML root must be a table")
return value
def version_tuple(value: str, path: Path) -> tuple[int, ...]:
if not isinstance(value, str) or not VERSION_RE.fullmatch(value):
raise CheckError(f"{path}: version must be dotted numeric")
return tuple(int(part) for part in value.split("."))
def compare_versions(left: str, right: str, path: Path) -> int:
left_parts = list(version_tuple(left, path))
right_parts = list(version_tuple(right, path))
width = max(len(left_parts), len(right_parts))
left_parts.extend([0] * (width - len(left_parts)))
right_parts.extend([0] * (width - len(right_parts)))
return (left_parts > right_parts) - (left_parts < right_parts)
def validate_manifest(path: Path, engine_version: int) -> dict:
manifest = load_toml(path)
unknown = sorted(set(manifest) - MANIFEST_KEYS)
if unknown:
raise CheckError(f"{path}: unknown manifest field(s): {', '.join(unknown)}")
agent_id = manifest.get("id")
if not isinstance(agent_id, str) or not agent_id.strip():
raise CheckError(f"{path}: id must be a non-empty string")
version = manifest.get("version")
version_tuple(version, path)
min_engine = manifest.get("min_engine_version")
if not isinstance(min_engine, int):
raise CheckError(f"{path}: min_engine_version must be an integer")
if min_engine > engine_version:
raise CheckError(
f"{path}: min_engine_version {min_engine} exceeds engine {engine_version}"
)
aliases = manifest.get("aliases", [])
if not isinstance(aliases, list) or not all(isinstance(item, str) for item in aliases):
raise CheckError(f"{path}: aliases must be an array of strings")
rules = manifest.get("rules")
if not isinstance(rules, list) or not rules:
raise CheckError(f"{path}: rules must be a non-empty array")
if len(rules) > MAX_RULES_PER_MANIFEST:
raise CheckError(f"{path}: manifest exceeds max rule count {MAX_RULES_PER_MANIFEST}")
complexity = {"gates": 0, "matchers": 0}
for index, rule in enumerate(rules):
validate_rule(path, index, rule, complexity)
return manifest
def validate_rule(path: Path, index: int, rule: object, complexity: dict[str, int]) -> None:
if not isinstance(rule, dict):
raise CheckError(f"{path}: rule {index} must be a table")
unknown = sorted(set(rule) - RULE_KEYS)
if unknown:
raise CheckError(f"{path}: rule {index} has unknown field(s): {', '.join(unknown)}")
rule_id = rule.get("id")
if not isinstance(rule_id, str) or not rule_id.strip():
raise CheckError(f"{path}: rule {index} id must be a non-empty string")
state = rule.get("state")
if state is not None and state not in STATES:
raise CheckError(f"{path}: rule {rule_id} has invalid state {state!r}")
region = rule.get("region", "whole_recent")
if not isinstance(region, str) or not REGION_RE.fullmatch(region):
raise CheckError(f"{path}: rule {rule_id} has invalid region {region!r}")
if rule.get("skip_state_update"):
if state != "unknown":
raise CheckError(f"{path}: rule {rule_id} skip_state_update requires state unknown")
if rule.get("visible_idle") or rule.get("visible_blocker") or rule.get("visible_working"):
raise CheckError(f"{path}: rule {rule_id} skip_state_update cannot set visible flags")
validate_gate(path, f"rule {rule_id}", rule, require_positive=True, depth=0, complexity=complexity)
def validate_gate(
path: Path,
label: str,
gate: dict,
require_positive: bool,
depth: int,
complexity: dict[str, int],
) -> None:
if depth > MAX_GATE_DEPTH:
raise CheckError(f"{path}: {label} exceeds max gate depth {MAX_GATE_DEPTH}")
complexity["gates"] += 1
if complexity["gates"] > MAX_TOTAL_GATES:
raise CheckError(f"{path}: manifest exceeds max gate count {MAX_TOTAL_GATES}")
unknown = sorted(set(gate) - (RULE_KEYS if label.startswith("rule ") else GATE_KEYS))
if unknown:
raise CheckError(f"{path}: {label} has unknown gate field(s): {', '.join(unknown)}")
matcher_count = 0
for key in ("contains", "regex", "line_regex"):
values = gate.get(key, [])
if not isinstance(values, list) or not all(isinstance(item, str) for item in values):
raise CheckError(f"{path}: {label} {key} must be an array of strings")
matcher_count += len(values)
for value in values:
if len(value) > MAX_MATCHER_CHARS:
raise CheckError(f"{path}: {label} matcher exceeds max length {MAX_MATCHER_CHARS}")
if matcher_count > MAX_MATCHERS_PER_GATE:
raise CheckError(f"{path}: {label} exceeds max direct matcher count {MAX_MATCHERS_PER_GATE}")
complexity["matchers"] += matcher_count
if complexity["matchers"] > MAX_TOTAL_MATCHERS:
raise CheckError(f"{path}: manifest exceeds max matcher count {MAX_TOTAL_MATCHERS}")
nested_any = gate.get("any", [])
nested_all = gate.get("all", [])
nested_not = gate.get("not", [])
for key, values in (("any", nested_any), ("all", nested_all), ("not", nested_not)):
if not isinstance(values, list):
raise CheckError(f"{path}: {label} {key} must be an array")
if require_positive and not has_positive_matcher(gate):
raise CheckError(f"{path}: {label} must contain a positive matcher")
for idx, nested in enumerate(nested_any):
validate_nested_gate(path, f"{label} any[{idx}]", nested, require_positive=True, depth=depth + 1, complexity=complexity)
for idx, nested in enumerate(nested_all):
validate_nested_gate(path, f"{label} all[{idx}]", nested, require_positive=True, depth=depth + 1, complexity=complexity)
for idx, nested in enumerate(nested_not):
validate_nested_gate(path, f"{label} not[{idx}]", nested, require_positive=False, depth=depth + 1, complexity=complexity)
def validate_nested_gate(
path: Path,
label: str,
gate: object,
require_positive: bool,
depth: int,
complexity: dict[str, int],
) -> None:
if not isinstance(gate, dict):
raise CheckError(f"{path}: {label} must be a table")
if not require_positive and not has_any_matcher(gate):
raise CheckError(f"{path}: {label} must contain a matcher")
validate_gate(path, label, gate, require_positive=require_positive, depth=depth, complexity=complexity)
def has_positive_matcher(gate: dict) -> bool:
return bool(gate.get("contains") or gate.get("regex") or gate.get("line_regex") or gate.get("any") or gate.get("all"))
def has_any_matcher(gate: dict) -> bool:
return bool(
gate.get("contains")
or gate.get("regex")
or gate.get("line_regex")
or gate.get("any")
or gate.get("all")
or gate.get("not")
)
def load_manifest_dir(path: Path, engine_version: int) -> dict[str, tuple[Path, dict]]:
if not path.is_dir():
raise CheckError(f"{path}: manifest directory is missing")
manifests: dict[str, tuple[Path, dict]] = {}
for manifest_path in sorted(path.glob("*.toml")):
if manifest_path.name == "index.toml":
continue
manifest = validate_manifest(manifest_path, engine_version)
agent_id = manifest["id"]
if agent_id in manifests:
raise CheckError(
f"{manifest_path}: duplicate manifest id {agent_id!r}; already seen in {manifests[agent_id][0]}"
)
manifests[agent_id] = (manifest_path, manifest)
if not manifests:
raise CheckError(f"{path}: no manifests found")
return manifests
def validate_catalog(
website_dir: Path,
bundled: dict[str, tuple[Path, dict]],
engine_version: int,
) -> None:
catalog_path = website_dir / "index.toml"
catalog = load_toml(catalog_path)
if set(catalog) != {"schema_version", "agents"}:
raise CheckError(f"{catalog_path}: expected only schema_version and agents")
if catalog.get("schema_version") != 1:
raise CheckError(f"{catalog_path}: schema_version must be 1")
agents = catalog.get("agents")
if not isinstance(agents, list):
raise CheckError(f"{catalog_path}: agents must be an array")
seen: dict[str, str] = {}
for entry in agents:
if not isinstance(entry, dict) or set(entry) != {"id", "path"}:
raise CheckError(f"{catalog_path}: each agent entry must contain id and path")
agent_id = entry["id"]
rel_path = entry["path"]
if not isinstance(agent_id, str) or not isinstance(rel_path, str):
raise CheckError(f"{catalog_path}: agent id and path must be strings")
if agent_id in seen:
raise CheckError(f"{catalog_path}: duplicate catalog agent {agent_id}")
if "://" in rel_path or rel_path.startswith("/") or ".." in Path(rel_path).parts:
raise CheckError(f"{catalog_path}: unsafe path for {agent_id}: {rel_path}")
if agent_id not in bundled:
raise CheckError(f"{catalog_path}: unknown agent {agent_id}; binary cannot identify it")
manifest_path = website_dir / rel_path
manifest = validate_manifest(manifest_path, engine_version)
if manifest["id"] != agent_id:
raise CheckError(f"{manifest_path}: id {manifest['id']} does not match catalog {agent_id}")
seen[agent_id] = rel_path
bundled_path, bundled_manifest = bundled[agent_id]
cmp = compare_versions(manifest["version"], bundled_manifest["version"], manifest_path)
if cmp < 0:
raise CheckError(
f"{manifest_path}: website version {manifest['version']} is lower than bundled "
f"{bundled_manifest['version']} in {bundled_path}"
)
if cmp == 0 and manifest_path.read_text(encoding="utf-8") != bundled_path.read_text(encoding="utf-8"):
raise CheckError(
f"{manifest_path}: same version as bundled {bundled_manifest['version']} but content differs"
)
missing = sorted(set(bundled) - set(seen))
if missing:
raise CheckError(f"{catalog_path}: missing bundled agent(s): {', '.join(missing)}")
catalog_paths = set(seen.values()) | {"index.toml"}
extra = sorted(path.name for path in website_dir.glob("*.toml") if path.name not in catalog_paths)
if extra:
raise CheckError(f"{website_dir}: TOML file(s) not listed in catalog: {', '.join(extra)}")
def main() -> int:
args = parse_args()
try:
engine_version = read_engine_version(args.engine_version)
bundled = load_manifest_dir(args.bundled_dir, engine_version)
if args.require_website or args.website_dir.exists():
if not args.website_dir.is_dir():
raise CheckError(f"{args.website_dir}: website manifest directory is missing")
validate_catalog(args.website_dir, bundled, engine_version)
except CheckError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print("agent detection manifests ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())

572
scripts/capture_agent_screen.py Executable file
View File

@ -0,0 +1,572 @@
#!/usr/bin/env python3
"""Capture repeated Herdr pane reads for agent screen detection fixtures."""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
DEFAULT_OUT_DIR = Path(".local/agent-screen-captures")
DEFAULT_PANE = "harness-test"
STATE_CHOICES = {
"i": "idle",
"idle": "idle",
"w": "working",
"working": "working",
"b": "blocked",
"blocked": "blocked",
"d": "done",
"done": "done",
"u": "unknown",
"unknown": "unknown",
"c": "custom",
"custom": "custom",
}
@dataclass
class CommandResult:
code: int
stdout: bytes
stderr: bytes
@dataclass
class PaneMatch:
pane_id: str
label: str | None
name: str | None
agent: str | None
title: str | None
display_agent: str | None
raw: dict[str, Any] | None
agent_raw: dict[str, Any] | None = None
def main() -> int:
args = parse_args()
run_dir = args.out / datetime.now().strftime("%Y%m%d-%H%M%S")
run_dir.mkdir(parents=True, exist_ok=True)
print(f"writing captures under {run_dir}")
print("state shortcuts: i=idle, w=working, b=blocked, d=done, u=unknown, c=custom, q=quit")
capture_index = 1
while True:
pane = resolve_target(args.herdr, args.pane, args.agent)
if pane is None:
if args.agent:
print(f"agent '{args.agent}' was not found by `herdr agent get`")
else:
print(f"pane '{args.pane}' was not found by `herdr pane list`")
print("pass a pane id with --pane, or rename the target pane to harness-test")
return 1
print(f"\npane found: {pane.pane_id}, agent: {agent_display(pane)}{format_pane_context(pane)}")
state = prompt_state()
if state is None:
break
name = prompt_name(pane, state)
if name is None:
break
capture_dir = run_dir / f"{capture_index:03d}-{slugify(name)}"
capture_dir.mkdir(parents=True, exist_ok=False)
print(f"capturing {args.samples} sample(s) into {capture_dir}")
started_at = iso_now()
failures = capture_case(args, pane, state, name, capture_dir)
write_metadata(
capture_dir,
args=args,
pane=pane,
state=state,
name=name,
started_at=started_at,
finished_at=iso_now(),
failures=failures,
)
if failures:
print(f"saved with {len(failures)} command failure(s); see metadata.toml")
else:
print("saved")
capture_index += 1
if args.once:
break
print("done")
return 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Interactively capture Herdr pane screen reads for agent detection fixture work."
)
parser.add_argument(
"--pane",
default=DEFAULT_PANE,
help=f"pane label/title/id to capture when --agent is not set (default: {DEFAULT_PANE})",
)
parser.add_argument(
"--agent",
help="agent target to capture; resolved with `herdr agent get`",
)
parser.add_argument(
"--out",
type=Path,
default=DEFAULT_OUT_DIR,
help=f"output directory (default: {DEFAULT_OUT_DIR})",
)
parser.add_argument(
"--samples",
type=positive_int,
default=5,
help="samples to capture per state (default: 5)",
)
parser.add_argument(
"--interval",
type=non_negative_float,
default=1.0,
help="seconds between samples (default: 1.0)",
)
parser.add_argument(
"--lines",
type=positive_int,
default=120,
help="recent-buffer lines to save per sample (default: 120)",
)
parser.add_argument(
"--herdr",
default="herdr",
help="Herdr CLI binary to call (default: herdr)",
)
parser.add_argument(
"--once",
action="store_true",
help="capture one state and exit",
)
return parser.parse_args()
def positive_int(value: str) -> int:
parsed = int(value)
if parsed <= 0:
raise argparse.ArgumentTypeError("must be greater than zero")
return parsed
def non_negative_float(value: str) -> float:
parsed = float(value)
if parsed < 0:
raise argparse.ArgumentTypeError("must be zero or greater")
return parsed
def resolve_pane(herdr: str, pane_ref: str) -> PaneMatch | None:
result = run_command([herdr, "pane", "list"])
if result.code != 0:
pane = fallback_pane_id(pane_ref)
return enrich_pane_with_agent(herdr, pane) if pane else None
try:
response = json.loads(result.stdout.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
pane = fallback_pane_id(pane_ref)
return enrich_pane_with_agent(herdr, pane) if pane else None
panes = response.get("result", {}).get("panes", [])
if not isinstance(panes, list):
pane = fallback_pane_id(pane_ref)
return enrich_pane_with_agent(herdr, pane) if pane else None
exact_matches = []
loose_matches = []
for pane in panes:
if not isinstance(pane, dict):
continue
pane_id = string_value(pane.get("pane_id"))
if pane_id is None:
continue
fields = pane_match_fields(pane)
if any(field == pane_ref for field in fields):
exact_matches.append(pane)
elif any(field and pane_ref in field for field in fields):
loose_matches.append(pane)
matches = exact_matches or loose_matches
if len(matches) == 1:
return enrich_pane_with_agent(herdr, pane_from_dict(matches[0]))
if len(matches) > 1:
print(f"pane ref '{pane_ref}' matched multiple panes:")
for pane in matches:
print(f" {pane.get('pane_id')}{format_pane_context(pane_from_dict(pane))}")
return None
pane = fallback_pane_id(pane_ref)
return enrich_pane_with_agent(herdr, pane) if pane else None
def resolve_target(herdr: str, pane_ref: str, agent_ref: str | None) -> PaneMatch | None:
if agent_ref:
agent = get_agent(herdr, agent_ref)
if agent is None:
return None
pane = pane_from_agent_dict(agent)
return enrich_pane_with_pane_list(herdr, pane)
return resolve_pane(herdr, pane_ref)
def get_agent(herdr: str, agent_ref: str) -> dict[str, Any] | None:
result = run_command([herdr, "agent", "get", agent_ref])
if result.code != 0:
return None
try:
response = json.loads(result.stdout.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
agent = response.get("result", {}).get("agent")
return agent if isinstance(agent, dict) else None
def enrich_pane_with_agent(herdr: str, pane: PaneMatch) -> PaneMatch:
result = run_command([herdr, "agent", "list"])
if result.code != 0:
return pane
try:
response = json.loads(result.stdout.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return pane
agents = response.get("result", {}).get("agents", [])
if not isinstance(agents, list):
return pane
for agent in agents:
if isinstance(agent, dict) and agent.get("pane_id") == pane.pane_id:
return merge_agent_into_pane(pane, agent)
return pane
def enrich_pane_with_pane_list(herdr: str, pane: PaneMatch) -> PaneMatch:
result = run_command([herdr, "pane", "list"])
if result.code != 0:
return pane
try:
response = json.loads(result.stdout.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return pane
panes = response.get("result", {}).get("panes", [])
if not isinstance(panes, list):
return pane
for candidate in panes:
if isinstance(candidate, dict) and candidate.get("pane_id") == pane.pane_id:
pane_info = pane_from_dict(candidate)
pane_info.name = pane.name or pane_info.name
pane_info.agent = pane.agent or pane_info.agent
pane_info.display_agent = pane.display_agent or pane_info.display_agent
pane_info.agent_raw = pane.agent_raw
return pane_info
return pane
def merge_agent_into_pane(pane: PaneMatch, agent: dict[str, Any]) -> PaneMatch:
pane.name = string_value(agent.get("name")) or pane.name
pane.agent = string_value(agent.get("agent")) or pane.agent
pane.display_agent = string_value(agent.get("display_agent")) or pane.display_agent
pane.title = string_value(agent.get("title")) or pane.title
pane.agent_raw = agent
return pane
def fallback_pane_id(pane_ref: str) -> PaneMatch | None:
if re.fullmatch(r"p_(?:[A-Za-z0-9]+_)?\d+", pane_ref) or re.fullmatch(
r"[A-Za-z0-9_]+-\d+", pane_ref
):
return PaneMatch(
pane_id=normalize_pane_id(pane_ref),
label=None,
name=None,
agent=None,
title=None,
display_agent=None,
raw=None,
)
return None
def normalize_pane_id(pane_ref: str) -> str:
return pane_ref
def pane_match_fields(pane: dict[str, Any]) -> list[str]:
fields = [
string_value(pane.get("pane_id")),
string_value(pane.get("terminal_id")),
string_value(pane.get("label")),
string_value(pane.get("title")),
string_value(pane.get("agent")),
string_value(pane.get("display_agent")),
]
return [field for field in fields if field]
def pane_from_dict(pane: dict[str, Any]) -> PaneMatch:
return PaneMatch(
pane_id=string_value(pane.get("pane_id")) or "",
label=string_value(pane.get("label")),
name=None,
agent=string_value(pane.get("agent")),
title=string_value(pane.get("title")),
display_agent=string_value(pane.get("display_agent")),
raw=pane,
)
def pane_from_agent_dict(agent: dict[str, Any]) -> PaneMatch:
return PaneMatch(
pane_id=string_value(agent.get("pane_id")) or "",
label=None,
name=string_value(agent.get("name")),
agent=string_value(agent.get("agent")),
title=string_value(agent.get("title")),
display_agent=string_value(agent.get("display_agent")),
raw=None,
agent_raw=agent,
)
def agent_display(pane: PaneMatch) -> str:
return pane.agent or pane.display_agent or "unknown"
def string_value(value: Any) -> str | None:
return value if isinstance(value, str) and value else None
def format_pane_context(pane: PaneMatch) -> str:
details = []
if pane.name:
details.append(f"name={pane.name}")
if pane.label:
details.append(f"label={pane.label}")
if pane.title:
details.append(f"title={pane.title}")
if not details:
return ""
return " (" + ", ".join(details) + ")"
def prompt_state() -> str | None:
while True:
raw = input("state [idle/working/blocked/done/unknown/custom/q]: ").strip()
if raw.lower() in {"q", "quit", "exit"}:
return None
if not raw:
continue
state = STATE_CHOICES.get(raw.lower())
if state == "custom":
custom = input("custom state label: ").strip()
if custom:
return custom
continue
if state:
return state
return raw
def prompt_name(pane: PaneMatch, state: str) -> str | None:
default_parts = [
pane.agent or pane.display_agent or pane.name or pane.label or "agent",
state,
datetime.now().strftime("%H%M%S"),
]
default_name = "-".join(slugify(part) for part in default_parts if part)
raw = input(f"capture name [{default_name}]: ").strip()
if raw.lower() in {"q", "quit", "exit"}:
return None
return raw or default_name
def capture_case(
args: argparse.Namespace,
pane: PaneMatch,
state: str,
name: str,
capture_dir: Path,
) -> list[str]:
failures: list[str] = []
for index in range(1, args.samples + 1):
prefix = f"sample-{index:03d}"
print(f" sample {index}/{args.samples}")
commands = [
(
f"{prefix}.detection.txt",
[args.herdr, "pane", "read", pane.pane_id, "--source", "detection", "--format", "text"],
),
(
f"{prefix}.detection.ansi",
[args.herdr, "pane", "read", pane.pane_id, "--source", "detection", "--format", "ansi"],
),
(
f"{prefix}.recent.txt",
[
args.herdr,
"pane",
"read",
pane.pane_id,
"--source",
"recent",
"--lines",
str(args.lines),
"--format",
"text",
],
),
(
f"{prefix}.recent.ansi",
[
args.herdr,
"pane",
"read",
pane.pane_id,
"--source",
"recent",
"--lines",
str(args.lines),
"--format",
"ansi",
],
),
(
f"{prefix}.explain.json",
[args.herdr, "agent", "explain", pane.pane_id, "--json"],
),
]
for filename, command in commands:
result = run_command(command)
output_path = capture_dir / filename
if result.code == 0:
output_path.write_bytes(result.stdout)
else:
failures.append(f"{filename}: exit {result.code}: {' '.join(command)}")
output_path.with_suffix(output_path.suffix + ".stderr").write_bytes(result.stderr)
output_path.write_bytes(result.stdout)
sample_meta = {
"captured_at": iso_now(),
"sample": index,
"state": state,
"name": name,
}
(capture_dir / f"{prefix}.json").write_text(
json.dumps(sample_meta, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
if index < args.samples:
time.sleep(args.interval)
return failures
def run_command(command: list[str]) -> CommandResult:
try:
completed = subprocess.run(command, capture_output=True, check=False)
except FileNotFoundError as err:
return CommandResult(code=127, stdout=b"", stderr=str(err).encode("utf-8"))
return CommandResult(
code=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
)
def write_metadata(
capture_dir: Path,
*,
args: argparse.Namespace,
pane: PaneMatch,
state: str,
name: str,
started_at: str,
finished_at: str,
failures: list[str],
) -> None:
lines = [
f"name = {toml_string(name)}",
f"state = {toml_string(state)}",
f"started_at = {toml_string(started_at)}",
f"finished_at = {toml_string(finished_at)}",
f"pane_ref = {toml_string(args.pane)}",
f"agent_ref = {toml_optional_string(args.agent)}",
f"pane_id = {toml_string(pane.pane_id)}",
f"samples = {args.samples}",
f"interval_seconds = {args.interval}",
f"recent_lines = {args.lines}",
f"herdr = {toml_string(args.herdr)}",
f"pane_label = {toml_optional_string(pane.label)}",
f"agent_name = {toml_optional_string(pane.name)}",
f"pane_agent = {toml_optional_string(pane.agent)}",
f"display_agent = {toml_optional_string(pane.display_agent)}",
f"pane_title = {toml_optional_string(pane.title)}",
"",
"[commands]",
'detection_text = "herdr pane read <pane> --source detection --format text"',
'detection_ansi = "herdr pane read <pane> --source detection --format ansi"',
'recent_text = "herdr pane read <pane> --source recent --lines <n> --format text"',
'recent_ansi = "herdr pane read <pane> --source recent --lines <n> --format ansi"',
'explain = "herdr agent explain <pane> --json"',
]
if failures:
lines.append("")
lines.append("failures = [")
for failure in failures:
lines.append(f" {toml_string(failure)},")
lines.append("]")
(capture_dir / "metadata.toml").write_text("\n".join(lines) + "\n", encoding="utf-8")
if pane.raw is not None:
(capture_dir / "pane.json").write_text(
json.dumps(pane.raw, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
if pane.agent_raw is not None:
(capture_dir / "agent.json").write_text(
json.dumps(pane.agent_raw, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def toml_optional_string(value: str | None) -> str:
if value is None:
return '""'
return toml_string(value)
def toml_string(value: str) -> str:
return json.dumps(value)
def slugify(value: str) -> str:
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip().lower())
slug = re.sub(r"-+", "-", slug).strip("-")
return slug or "capture"
def iso_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,106 @@
import tempfile
import unittest
from pathlib import Path
from scripts import agent_detection_manifest_check as check
def manifest(agent_id: str, version: str, contains: str = "ready") -> str:
return f'''id = "{agent_id}"
version = "{version}"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
[[rules]]
id = "idle"
state = "idle"
contains = ["{contains}"]
'''
def catalog(agent_id: str = "codex", path: str = "codex.toml") -> str:
return f'''schema_version = 1
[[agents]]
id = "{agent_id}"
path = "{path}"
'''
class AgentDetectionManifestCheckTests(unittest.TestCase):
def test_validates_bundled_and_matching_website_catalog(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
bundled = root / "bundled"
website = root / "website"
bundled.mkdir()
website.mkdir()
content = manifest("codex", "2026.06.10.1")
(bundled / "codex.toml").write_text(content)
(website / "codex.toml").write_text(content)
(website / "index.toml").write_text(catalog())
bundled_manifests = check.load_manifest_dir(bundled, engine_version=1)
check.validate_catalog(website, bundled_manifests, engine_version=1)
def test_rejects_website_version_lower_than_bundled(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
bundled = root / "bundled"
website = root / "website"
bundled.mkdir()
website.mkdir()
(bundled / "codex.toml").write_text(manifest("codex", "2026.06.10.2"))
(website / "codex.toml").write_text(manifest("codex", "2026.06.10.1"))
(website / "index.toml").write_text(catalog())
bundled_manifests = check.load_manifest_dir(bundled, engine_version=1)
with self.assertRaisesRegex(check.CheckError, "lower than bundled"):
check.validate_catalog(website, bundled_manifests, engine_version=1)
def test_rejects_same_version_content_drift(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
bundled = root / "bundled"
website = root / "website"
bundled.mkdir()
website.mkdir()
(bundled / "codex.toml").write_text(manifest("codex", "2026.06.10.1", "ready"))
(website / "codex.toml").write_text(manifest("codex", "2026.06.10.1", "changed"))
(website / "index.toml").write_text(catalog())
bundled_manifests = check.load_manifest_dir(bundled, engine_version=1)
with self.assertRaisesRegex(check.CheckError, "same version"):
check.validate_catalog(website, bundled_manifests, engine_version=1)
def test_rejects_unknown_catalog_agent(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
bundled = root / "bundled"
website = root / "website"
bundled.mkdir()
website.mkdir()
(bundled / "codex.toml").write_text(manifest("codex", "2026.06.10.1"))
(website / "newagent.toml").write_text(manifest("newagent", "2026.06.10.1"))
(website / "index.toml").write_text(catalog("newagent", "newagent.toml"))
bundled_manifests = check.load_manifest_dir(bundled, engine_version=1)
with self.assertRaisesRegex(check.CheckError, "unknown agent"):
check.validate_catalog(website, bundled_manifests, engine_version=1)
def test_rejects_manifest_requiring_newer_engine(self):
with tempfile.TemporaryDirectory() as tmp:
bundled = Path(tmp) / "bundled"
bundled.mkdir()
(bundled / "codex.toml").write_text(
manifest("codex", "2026.06.10.1").replace(
"min_engine_version = 1", "min_engine_version = 2"
)
)
with self.assertRaisesRegex(check.CheckError, "exceeds engine"):
check.load_manifest_dir(bundled, engine_version=1)
if __name__ == "__main__":
unittest.main()

View File

@ -1,293 +0,0 @@
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::Hermes | Agent::OpenCode | Agent::Kilo
)
}
pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> bool {
matches!(
(source, agent_label),
("herdr:pi", "pi")
| ("herdr:omp", "omp")
| ("herdr:hermes", "hermes")
| ("herdr:opencode", "opencode")
| ("herdr:kilo", "kilo")
)
}
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:hermes", "hermes"));
assert!(full_lifecycle_hook_authority("herdr:opencode", "opencode"));
assert!(full_lifecycle_hook_authority("herdr:kilo", "kilo"));
assert!(!full_lifecycle_hook_authority("herdr:copilot", "copilot"));
assert!(!full_lifecycle_hook_authority("herdr:codex", "codex"));
assert!(!full_lifecycle_hook_authority("herdr:claude", "claude"));
assert!(!full_lifecycle_hook_authority("herdr:cursor", "cursor"));
assert!(!full_lifecycle_hook_authority("herdr:kimi", "kimi"));
assert!(!full_lifecycle_hook_authority("herdr:droid", "droid"));
assert!(!full_lifecycle_hook_authority("herdr:qodercli", "qodercli"));
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::Hermes));
assert!(full_lifecycle_detected_agent(Agent::OpenCode));
assert!(full_lifecycle_detected_agent(Agent::Kilo));
assert!(!full_lifecycle_detected_agent(Agent::GithubCopilot));
assert!(!full_lifecycle_detected_agent(Agent::Kimi));
assert!(!full_lifecycle_detected_agent(Agent::Droid));
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,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
matches!(
&request.method,
Method::ServerReloadConfig(_)
| Method::ServerReloadAgentManifests(_)
| Method::NotificationShow(_)
| Method::WorkspaceCreate(_)
| Method::WorkspaceFocus(_)

View File

@ -6,6 +6,10 @@ pub mod panes;
pub use panes::*;
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Request {
pub id: String,
@ -24,6 +28,10 @@ pub enum Method {
ServerLiveHandoff(ServerLiveHandoffParams),
#[serde(rename = "server.reload_config")]
ServerReloadConfig(EmptyParams),
#[serde(rename = "server.agent_manifests")]
ServerAgentManifests(EmptyParams),
#[serde(rename = "server.reload_agent_manifests")]
ServerReloadAgentManifests(EmptyParams),
#[serde(rename = "notification.show")]
NotificationShow(NotificationShowParams),
#[serde(rename = "workspace.create")]
@ -64,6 +72,8 @@ pub enum Method {
AgentGet(AgentTarget),
#[serde(rename = "agent.read")]
AgentRead(AgentReadParams),
#[serde(rename = "agent.explain")]
AgentExplain(AgentTarget),
#[serde(rename = "agent.send")]
AgentSend(AgentSendParams),
#[serde(rename = "agent.rename")]
@ -342,6 +352,7 @@ pub enum ReadSource {
Visible,
Recent,
RecentUnwrapped,
Detection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
@ -665,6 +676,9 @@ pub enum ResponseResult {
PaneRead {
read: PaneReadResult,
},
AgentExplain {
explain: serde_json::Value,
},
SubscriptionStarted {},
WaitMatched {
event: EventEnvelope,
@ -687,6 +701,16 @@ pub enum ResponseResult {
target: IntegrationTarget,
details: IntegrationUninstallResult,
},
AgentManifestReload {
manifests: Vec<AgentManifestInfo>,
},
AgentManifestStatus {
#[serde(default, skip_serializing_if = "Option::is_none")]
last_check_unix: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
last_result: Option<String>,
manifests: Vec<AgentManifestInfo>,
},
ConfigReload {
status: crate::config::ConfigReloadStatus,
diagnostics: Vec<String>,
@ -694,6 +718,26 @@ pub enum ResponseResult {
Ok {},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentManifestInfo {
pub agent: String,
pub source: String,
pub source_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cached_remote_version: Option<String>,
pub local_override_shadowing_remote: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_update_result: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_update_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_last_checked_unix: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceInfo {
pub workspace_id: String,
@ -764,6 +808,8 @@ pub struct AgentInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_agent: Option<String>,
pub agent_status: AgentStatus,
#[serde(default, skip_serializing_if = "is_false")]
pub screen_detection_skipped: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_status: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
@ -1115,6 +1161,47 @@ mod tests {
assert_eq!(restored, request);
}
#[test]
fn request_round_trips_for_server_reload_agent_manifests() {
let request = Request {
id: "req_reload_agent_manifests".into(),
method: Method::ServerReloadAgentManifests(EmptyParams::default()),
};
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["method"], "server.reload_agent_manifests");
let restored: Request = serde_json::from_value(json).unwrap();
assert_eq!(restored, request);
}
#[test]
fn request_round_trips_for_server_agent_manifests() {
let request = Request {
id: "req_agent_manifests".into(),
method: Method::ServerAgentManifests(EmptyParams::default()),
};
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["method"], "server.agent_manifests");
let restored: Request = serde_json::from_value(json).unwrap();
assert_eq!(restored, request);
}
#[test]
fn request_round_trips_for_agent_explain() {
let request = Request {
id: "req_agent_explain".into(),
method: Method::AgentExplain(AgentTarget {
target: "agent-1".into(),
}),
};
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["method"], "agent.explain");
let restored: Request = serde_json::from_value(json).unwrap();
assert_eq!(restored, request);
}
#[test]
fn notification_show_request_parses() {
let json = r#"{"id":"req_1","method":"notification.show","params":{"title":"build failed","body":"api workspace","position":"top-left","sound":"request"}}"#;

View File

@ -276,6 +276,8 @@ fn api_method_name(method: &Method) -> &'static str {
Method::ServerStop(_) => "server.stop",
Method::ServerLiveHandoff(_) => "server.live_handoff",
Method::ServerReloadConfig(_) => "server.reload_config",
Method::ServerAgentManifests(_) => "server.agent_manifests",
Method::ServerReloadAgentManifests(_) => "server.reload_agent_manifests",
Method::NotificationShow(_) => "notification.show",
Method::WorkspaceCreate(_) => "workspace.create",
Method::WorkspaceList(_) => "workspace.list",
@ -296,6 +298,7 @@ fn api_method_name(method: &Method) -> &'static str {
Method::AgentList(_) => "agent.list",
Method::AgentGet(_) => "agent.get",
Method::AgentRead(_) => "agent.read",
Method::AgentExplain(_) => "agent.explain",
Method::AgentSend(_) => "agent.send",
Method::AgentRename(_) => "agent.rename",
Method::AgentFocus(_) => "agent.focus",

View File

@ -2312,6 +2312,36 @@ impl AppState {
}
Vec::new()
}
AppEvent::AgentDetectionManifestsUpdated { updated, status } => {
self.agent_manifest_update_status = status;
self.refresh_agent_manifest_summaries();
if !updated.is_empty()
&& matches!(
self.toast_config.delivery,
crate::config::ToastDelivery::Herdr
)
{
let agent_list = updated
.iter()
.map(|item| {
format!(
"{} {}",
crate::detect::agent_label(item.agent),
item.version
)
})
.collect::<Vec<_>>()
.join(", ");
self.toast = Some(ToastNotification {
kind: ToastKind::UpdateInstalled,
title: "Agent detection rules updated".to_string(),
context: agent_list,
position: None,
target: None,
});
}
Vec::new()
}
AppEvent::StateChanged {
pane_id,
agent,
@ -2520,14 +2550,15 @@ impl AppState {
) -> 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)?;
let agent = terminal.effective_known_agent().or(terminal.detected_agent);
if agent.is_none() && !terminal.full_lifecycle_hook_authority_active() {
return None;
}
Some(terminal.set_detected_state_with_screen_signals_at(
Some(agent),
agent,
AgentState::Idle,
false,
false,
true,
false,
true,
observed_at,
@ -4683,6 +4714,35 @@ mod tests {
);
}
#[test]
fn agent_detection_manifest_update_event_updates_status_and_toast() {
let mut state = AppState::test_new();
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
let status = crate::detect::manifest_update::ManifestUpdateStatus {
last_result: Some("checked".to_string()),
..Default::default()
};
let updates = state.handle_app_event(AppEvent::AgentDetectionManifestsUpdated {
updated: vec![crate::detect::manifest_update::ManifestUpdateCommit {
agent: Agent::Codex,
version: crate::detect::manifest_update::ManifestVersion::parse("2026.06.10.1")
.unwrap(),
}],
status,
});
assert!(updates.is_empty());
assert_eq!(
state.agent_manifest_update_status.last_result.as_deref(),
Some("checked")
);
let toast = state.toast.as_ref().expect("manifest update toast");
assert_eq!(toast.kind, ToastKind::UpdateInstalled);
assert_eq!(toast.title, "Agent detection rules updated");
assert_eq!(toast.context, "codex 2026.06.10.1");
}
#[test]
fn toggle_zoom_works() {
let mut state = app_with_workspaces(&["test"]);

View File

@ -415,6 +415,7 @@ impl App {
title: pane.title,
display_agent: pane.display_agent,
agent_status: pane.agent_status,
screen_detection_skipped: terminal.full_lifecycle_hook_authority_active(),
custom_status: pane.custom_status,
state_labels: pane.state_labels,
agent_session: pane.agent_session,

View File

@ -69,6 +69,7 @@ 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.sync_full_lifecycle_authority_detection_pauses();
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));
@ -123,17 +124,18 @@ impl App {
} else {
None
};
let manifest_update_agents =
if let AppEvent::AgentDetectionManifestsUpdated { updated, .. } = &ev {
Some(updated.iter().map(|item| item.agent).collect::<Vec<_>>())
} else {
None
};
let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. });
let previous_toast = self.state.toast.clone();
let pane_updates = self.state.handle_app_event(ev);
if terminal_cwd_reported {
self.mark_git_status_refresh_due(Instant::now());
if let Some(agents) = manifest_update_agents {
self.reset_agent_detection_for_agents(&agents);
}
for update in &pane_updates {
self.refresh_new_herdr_toast_context_for_update(update, &previous_toast);
self.emit_pane_state_update(update);
}
self.sync_agent_metadata_deadline();
if let Some((pane_id, agent)) = released_agent {
if pane_updates.iter().any(|update| update.pane_id == pane_id) {
if let Some((ws_idx, _)) = self.find_pane(pane_id) {
@ -147,6 +149,15 @@ impl App {
}
}
}
self.sync_full_lifecycle_authority_detection_pauses();
if terminal_cwd_reported {
self.mark_git_status_refresh_due(Instant::now());
}
for update in &pane_updates {
self.refresh_new_herdr_toast_context_for_update(update, &previous_toast);
self.emit_pane_state_update(update);
}
self.sync_agent_metadata_deadline();
if let Some(overlay) = overlay_state {
self.restore_overlay_after_exit(overlay);
}
@ -175,6 +186,29 @@ impl App {
self.shutdown_detached_terminal_runtimes();
}
fn reset_agent_detection_for_agents(&self, agents: &[crate::detect::Agent]) {
if agents.is_empty() {
return;
}
for (terminal_id, terminal) in &self.state.terminals {
let Some(agent) = terminal.effective_known_agent().or(terminal.detected_agent) else {
continue;
};
if !agents.contains(&agent) {
continue;
}
if let Some(runtime) = self.terminal_runtimes.get(terminal_id) {
runtime.reset_agent_detection();
}
}
}
fn reset_all_agent_detection_runtimes(&self) {
for runtime in self.terminal_runtimes.values() {
runtime.reset_agent_detection();
}
}
pub(crate) fn refresh_new_herdr_toast_context_for_update(
&mut self,
update: &crate::app::actions::PaneStateUpdate,
@ -218,6 +252,26 @@ impl App {
}
}
fn sync_full_lifecycle_authority_detection_pauses(&self) {
for workspace in &self.state.workspaces {
for tab in &workspace.tabs {
for pane in tab.panes.values() {
let Some(terminal) = self.state.terminals.get(&pane.attached_terminal_id)
else {
continue;
};
let Some(runtime) = self.terminal_runtimes.get(&pane.attached_terminal_id)
else {
continue;
};
runtime.set_full_lifecycle_authority_active(
terminal.full_lifecycle_hook_authority_active(),
);
}
}
}
}
pub(crate) fn show_clipboard_feedback(&mut self) {
if !self.state.toast_config.clipboard.enabled {
self.state.copy_feedback = None;
@ -624,6 +678,39 @@ impl App {
},
}
}
Method::ServerAgentManifests(_) => {
self.state.refresh_agent_manifest_summaries();
let update_status = crate::detect::manifest_update::load_status();
SuccessResponse {
id: request.id,
result: ResponseResult::AgentManifestStatus {
last_check_unix: update_status.last_check_unix,
last_result: update_status.last_result.clone(),
manifests: self
.state
.agent_manifest_summaries
.clone()
.into_iter()
.map(|summary| agent_manifest_info(summary, &update_status))
.collect(),
},
}
}
Method::ServerReloadAgentManifests(_) => {
let summaries = crate::detect::manifest::reload_manifests();
self.state.agent_manifest_summaries = summaries.clone();
let update_status = crate::detect::manifest_update::load_status();
self.reset_all_agent_detection_runtimes();
SuccessResponse {
id: request.id,
result: ResponseResult::AgentManifestReload {
manifests: summaries
.into_iter()
.map(|summary| agent_manifest_info(summary, &update_status))
.collect(),
},
}
}
Method::NotificationShow(params) => {
return self.handle_notification_show(request.id, params);
}
@ -661,6 +748,7 @@ impl App {
Method::AgentRename(params) => return self.handle_agent_rename(request.id, params),
Method::AgentStart(params) => return self.handle_agent_start(request.id, params),
Method::AgentRead(params) => return self.handle_agent_read(request.id, params),
Method::AgentExplain(target) => return self.handle_agent_explain(request.id, target),
Method::AgentSend(params) => return self.handle_agent_send(request.id, params),
Method::PaneSplit(params) => return self.handle_pane_split(request.id, params),
Method::PaneSwap(params) => return self.handle_pane_swap(request.id, params),
@ -838,6 +926,25 @@ fn sanitized_notification_text(value: &str, max_chars: usize) -> Option<String>
(!sanitized.is_empty()).then_some(sanitized)
}
fn agent_manifest_info(
summary: crate::detect::manifest::AgentManifestSummary,
update_status: &crate::detect::manifest_update::ManifestUpdateStatus,
) -> crate::api::schema::AgentManifestInfo {
let remote = update_status.agent_status(summary.agent);
crate::api::schema::AgentManifestInfo {
agent: crate::detect::agent_label(summary.agent).to_string(),
source: summary.active_source.label(),
source_kind: summary.active_source.kind().to_string(),
active_version: summary.active_version,
cached_remote_version: summary.cached_remote_version,
local_override_shadowing_remote: summary.local_override_shadowing_remote,
remote_update_result: remote.as_ref().map(|status| status.last_result.clone()),
remote_update_error: remote.as_ref().and_then(|status| status.last_error.clone()),
remote_last_checked_unix: remote.and_then(|status| status.last_checked_unix),
warning: summary.warning,
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -853,6 +960,229 @@ mod tests {
assert!(status.success(), "git init failed for {}", path.display());
}
#[tokio::test]
async fn manifest_update_event_resets_matching_agent_detection_runtime() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&crate::config::Config::default(),
true,
None,
api_rx,
crate::api::EventHub::default(),
);
app.state.workspaces = vec![crate::workspace::Workspace::test_new("manifest-reset")];
app.state.ensure_test_terminals();
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
.attached_terminal_id
.clone();
app.state
.terminals
.get_mut(&terminal_id)
.unwrap()
.detected_agent = Some(Agent::Codex);
let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
let reset_notify = runtime.agent_detection_reset_notify_for_test();
app.terminal_runtimes.insert(terminal_id, runtime);
app.handle_internal_event(AppEvent::AgentDetectionManifestsUpdated {
updated: vec![crate::detect::manifest_update::ManifestUpdateCommit {
agent: Agent::Codex,
version: crate::detect::manifest_update::ManifestVersion::parse("2026.06.10.1")
.unwrap(),
}],
status: crate::detect::manifest_update::ManifestUpdateStatus::default(),
});
tokio::time::timeout(
std::time::Duration::from_millis(50),
reset_notify.notified(),
)
.await
.expect("matching agent detection runtime should be reset");
}
#[tokio::test]
async fn server_reload_agent_manifests_resets_detection_runtimes() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&crate::config::Config::default(),
true,
None,
api_rx,
crate::api::EventHub::default(),
);
app.state.workspaces = vec![crate::workspace::Workspace::test_new("manifest-reload")];
app.state.ensure_test_terminals();
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
.attached_terminal_id
.clone();
let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
let reset_notify = runtime.agent_detection_reset_notify_for_test();
app.terminal_runtimes.insert(terminal_id, runtime);
let response = app.handle_api_request(crate::api::schema::Request {
id: "reload_manifests".into(),
method: crate::api::schema::Method::ServerReloadAgentManifests(
crate::api::schema::EmptyParams::default(),
),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
assert_eq!(response["result"]["type"], "agent_manifest_reload");
assert!(!response["result"]["manifests"]
.as_array()
.unwrap()
.is_empty());
tokio::time::timeout(
std::time::Duration::from_millis(50),
reset_notify.notified(),
)
.await
.expect("manual manifest reload should reset detection runtimes");
}
#[tokio::test]
async fn server_agent_manifests_reports_status_without_resetting_runtimes() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&crate::config::Config::default(),
true,
None,
api_rx,
crate::api::EventHub::default(),
);
app.state.workspaces = vec![crate::workspace::Workspace::test_new("manifest-status")];
app.state.ensure_test_terminals();
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
.attached_terminal_id
.clone();
let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
let reset_notify = runtime.agent_detection_reset_notify_for_test();
app.terminal_runtimes.insert(terminal_id, runtime);
let response = app.handle_api_request(crate::api::schema::Request {
id: "manifest_status".into(),
method: crate::api::schema::Method::ServerAgentManifests(
crate::api::schema::EmptyParams::default(),
),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
assert_eq!(response["result"]["type"], "agent_manifest_status");
assert!(!response["result"]["manifests"]
.as_array()
.unwrap()
.is_empty());
assert!(
tokio::time::timeout(
std::time::Duration::from_millis(10),
reset_notify.notified(),
)
.await
.is_err(),
"status request should not reset detection runtimes"
);
}
#[tokio::test]
async fn agent_explain_evaluates_with_server_manifest_cache() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&crate::config::Config::default(),
true,
None,
api_rx,
crate::api::EventHub::default(),
);
app.state.workspaces = vec![crate::workspace::Workspace::test_new("agent-explain")];
app.state.ensure_test_terminals();
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
.attached_terminal_id
.clone();
app.state
.terminals
.get_mut(&terminal_id)
.unwrap()
.detected_agent = Some(Agent::Codex);
let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(
80,
24,
b"press enter to confirm or esc to cancel",
);
app.terminal_runtimes.insert(terminal_id, runtime);
let target = app.public_pane_id(0, pane_id).unwrap();
let response = app.handle_api_request(crate::api::schema::Request {
id: "agent_explain".into(),
method: crate::api::schema::Method::AgentExplain(crate::api::schema::AgentTarget {
target,
}),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
assert_eq!(response["result"]["type"], "agent_explain");
assert_eq!(response["result"]["explain"]["state"], "blocked");
assert_eq!(
response["result"]["explain"]["matched_rule"]["id"],
"live_strong_blocker"
);
}
#[tokio::test]
async fn agent_explain_reports_hook_only_full_lifecycle_authority() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&crate::config::Config::default(),
true,
None,
api_rx,
crate::api::EventHub::default(),
);
app.state.workspaces = vec![crate::workspace::Workspace::test_new("agent-explain-omp")];
app.state.ensure_test_terminals();
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
.attached_terminal_id
.clone();
app.state
.terminals
.get_mut(&terminal_id)
.unwrap()
.set_hook_authority(
"herdr:omp".to_string(),
"omp".to_string(),
AgentState::Working,
None,
Some(1),
);
let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
app.terminal_runtimes.insert(terminal_id, runtime);
let target = app.public_pane_id(0, pane_id).unwrap();
let response = app.handle_api_request(crate::api::schema::Request {
id: "agent_explain_omp".into(),
method: crate::api::schema::Method::AgentExplain(crate::api::schema::AgentTarget {
target,
}),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
assert_eq!(response["result"]["type"], "agent_explain");
assert_eq!(response["result"]["explain"]["agent"], "omp");
assert_eq!(response["result"]["explain"]["state"], "working");
assert_eq!(
response["result"]["explain"]["screen_detection_skip_reason"],
"full_lifecycle_hook_authority"
);
assert_eq!(
response["result"]["explain"]["matched_rule"],
serde_json::Value::Null
);
}
#[cfg(unix)]
#[tokio::test]
async fn herdr_toast_context_uses_live_root_runtime_cwd_label() {

View File

@ -73,11 +73,13 @@ impl App {
ReadSource::Visible => pane.visible_text(),
ReadSource::Recent => pane.recent_text(requested_lines),
ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
ReadSource::Detection => pane.detection_text(),
},
ReadFormat::Ansi => match params.source {
ReadSource::Visible => pane.visible_ansi(),
ReadSource::Recent => pane.recent_ansi(requested_lines),
ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
ReadSource::Detection => pane.detection_text(),
},
};
@ -102,6 +104,67 @@ impl App {
)
}
pub(super) fn handle_agent_explain(&mut self, id: String, target: AgentTarget) -> String {
let resolved = match self.resolve_terminal_target(&target.target) {
Ok(resolved) => resolved,
Err(err) => return encode_error_body(id, self.agent_target_error_body(err)),
};
let Some((pane, _workspace_id)) = self.lookup_runtime(resolved.ws_idx, resolved.pane_id)
else {
return agent_not_found(id, &target.target);
};
let Some(terminal_id) = self
.state
.workspaces
.get(resolved.ws_idx)
.and_then(|workspace| workspace.terminal_id(resolved.pane_id))
else {
return agent_not_found(id, &target.target);
};
let Some(terminal) = self.state.terminals.get(terminal_id) else {
return agent_not_found(id, &target.target);
};
if terminal.full_lifecycle_hook_authority_active() {
let explain = serde_json::json!({
"agent": terminal.effective_agent_label().unwrap_or("unknown"),
"state": crate::detect::manifest::agent_state_label(terminal.state),
"manifest_source": null,
"manifest_version": null,
"cached_remote_version": null,
"local_override_shadowing_remote": false,
"remote_update_status": null,
"remote_update_error": null,
"matched_rule": null,
"visible_idle": false,
"visible_blocker": false,
"visible_working": false,
"screen_detection_skipped": true,
"screen_detection_skip_reason": "full_lifecycle_hook_authority",
"skip_state_update": false,
"skipped_update_reason": null,
"fallback_reason": null,
"warning": null,
"evaluated_rules": [],
});
return encode_success(id, ResponseResult::AgentExplain { explain });
}
let Some(agent) = terminal.effective_known_agent().or(terminal.detected_agent) else {
return encode_error(
id,
"agent_explain_unavailable",
format!(
"agent target {} does not have a detected agent label",
target.target
),
);
};
let explain = crate::detect::manifest::explain(agent, &pane.detection_text());
let value = crate::detect::manifest::explain_to_json_value(&explain);
encode_success(id, ResponseResult::AgentExplain { explain: value })
}
pub(super) fn handle_agent_send(&mut self, id: String, params: AgentSendParams) -> String {
let resolved = match self.resolve_terminal_target(&params.target) {
Ok(resolved) => resolved,

View File

@ -616,11 +616,13 @@ impl App {
ReadSource::Visible => pane.visible_text(),
ReadSource::Recent => pane.recent_text(requested_lines),
ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
ReadSource::Detection => pane.detection_text(),
},
ReadFormat::Ansi => match params.source {
ReadSource::Visible => pane.visible_ansi(),
ReadSource::Recent => pane.recent_ansi(requested_lines),
ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
ReadSource::Detection => pane.detection_text(),
},
};

View File

@ -603,6 +603,12 @@ mod tests {
KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()),
);
assert_eq!(state.settings.section, SettingsSection::Integrations);
update_settings_state(
&mut state,
KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()),
);
assert_eq!(state.settings.section, SettingsSection::PaneLabels);
}
#[test]

View File

@ -107,6 +107,7 @@ pub struct App {
pub(crate) next_resize_poll: Instant,
pub(crate) next_animation_tick: Option<Instant>,
pub(crate) next_auto_update_check: Option<Instant>,
pub(crate) next_agent_manifest_update_check: Option<Instant>,
pub(crate) agent_metadata_deadline: Option<Instant>,
pub(crate) pending_agent_resume_deadline: Option<Instant>,
pub(crate) selection_autoscroll_deadline: Option<Instant>,
@ -398,6 +399,8 @@ impl App {
state::Mode::Navigate
};
let agent_manifest_summaries = crate::detect::manifest::reload_manifests();
let mut state = AppState {
terminals: std::collections::HashMap::new(),
direct_attach_resize_locks: std::collections::HashSet::new(),
@ -535,6 +538,8 @@ impl App {
original_theme: None,
},
integration_recommendations: crate::integration::integration_recommendations(),
agent_manifest_summaries,
agent_manifest_update_status: crate::detect::manifest_update::load_status(),
integration_install_messages: Vec::new(),
global_menu: state::MenuListState::new(0),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
@ -557,6 +562,10 @@ impl App {
if auto_updates_enabled(no_session) {
let update_tx = event_tx.clone();
std::thread::spawn(move || crate::update::auto_update(update_tx));
let manifest_update_tx = event_tx.clone();
std::thread::spawn(move || {
crate::detect::manifest_update::auto_update(manifest_update_tx)
});
}
let last_focus = state.active.and_then(|idx| {
@ -585,6 +594,8 @@ impl App {
next_animation_tick: None,
next_auto_update_check: auto_updates_enabled(no_session)
.then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
next_agent_manifest_update_check: auto_updates_enabled(no_session)
.then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
agent_metadata_deadline: None,
pending_agent_resume_deadline: None,
session_save_deadline: None,
@ -635,6 +646,15 @@ impl App {
let pane_id_aliases = crate::persist::handoff_pane_aliases(snapshot, &workspaces);
app.no_session = false;
if auto_updates_enabled(app.no_session) {
let now = Instant::now();
app.next_auto_update_check = app
.state
.update_available
.is_none()
.then_some(now + AUTO_UPDATE_CHECK_INTERVAL);
app.next_agent_manifest_update_check = Some(now + AUTO_UPDATE_CHECK_INTERVAL);
}
app.state.detach_exits = false;
app.state.pane_id_aliases = pane_id_aliases;
app.state.workspaces = workspaces;

View File

@ -256,6 +256,13 @@ impl App {
self.run_auto_update_check();
}
if self
.next_agent_manifest_update_check
.is_some_and(|deadline| now >= deadline)
{
self.run_agent_manifest_update_check();
}
if self
.session_save_deadline
.is_some_and(|deadline| now >= deadline)
@ -448,6 +455,18 @@ impl App {
std::thread::spawn(move || crate::update::auto_update(update_tx));
}
pub(crate) fn run_agent_manifest_update_check(&mut self) {
if !auto_updates_enabled(self.no_session) {
self.next_agent_manifest_update_check = None;
return;
}
self.next_agent_manifest_update_check = Some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL);
let manifest_update_tx = self.event_tx.clone();
std::thread::spawn(move || crate::detect::manifest_update::auto_update(manifest_update_tx));
}
pub(crate) fn start_git_status_refresh_if_due(&mut self, now: Instant) {
let Some(deadline) = self.git_refresh_deadline() else {
return;
@ -531,6 +550,7 @@ impl App {
.then(|| self.git_refresh_deadline())
.flatten(),
self.next_auto_update_check,
self.next_agent_manifest_update_check,
self.agent_metadata_deadline,
self.pending_agent_resume_deadline,
self.session_save_deadline,

View File

@ -1377,6 +1377,10 @@ pub struct AppState {
pub settings: SettingsState,
/// Cached integration recommendations for onboarding/settings UI.
pub integration_recommendations: Vec<crate::integration::IntegrationRecommendation>,
/// Cached detection manifest source/version summaries for runtime/API status.
pub agent_manifest_summaries: Vec<crate::detect::manifest::AgentManifestSummary>,
/// Cached remote detection manifest update diagnostics for runtime/API status.
pub agent_manifest_update_status: crate::detect::manifest_update::ManifestUpdateStatus,
/// Result messages from the latest integration install action.
pub integration_install_messages: Vec<String>,
/// Highlight state for the bottom-right global launcher menu.
@ -1433,6 +1437,10 @@ impl AppState {
.any(|item| item.state == crate::integration::IntegrationStatusKind::Outdated)
}
pub(crate) fn refresh_agent_manifest_summaries(&mut self) {
self.agent_manifest_summaries = crate::detect::manifest::manifest_summaries();
}
pub(crate) fn global_menu_attention_badge_visible(&self) -> bool {
self.update_available.is_some() || self.integration_updates_available()
}
@ -1700,6 +1708,9 @@ impl AppState {
original_theme: None,
},
integration_recommendations: Vec::new(),
agent_manifest_summaries: Vec::new(),
agent_manifest_update_status:
crate::detect::manifest_update::ManifestUpdateStatus::default(),
integration_install_messages: Vec::new(),
global_menu: MenuListState::new(0),
host_terminal_theme: TerminalTheme::default(),

View File

@ -737,6 +737,7 @@ pub(super) fn parse_read_source(value: &str) -> std::io::Result<ReadSource> {
"visible" => Ok(ReadSource::Visible),
"recent" => Ok(ReadSource::Recent),
"recent-unwrapped" | "recent_unwrapped" => Ok(ReadSource::RecentUnwrapped),
"detection" => Ok(ReadSource::Detection),
_ => Err(std::io::Error::other(format!(
"invalid read source: {value}"
))),

View File

@ -19,6 +19,7 @@ pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
"wait" => agent_wait(&args[1..]),
"attach" => agent_attach(&args[1..]),
"start" => agent_start(&args[1..]),
"explain" => agent_explain(&args[1..]),
"help" | "--help" | "-h" => {
print_agent_help();
Ok(0)
@ -30,6 +31,219 @@ pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
}
}
fn agent_explain(args: &[String]) -> std::io::Result<i32> {
let mut file = None;
let mut agent = None;
let mut json = false;
let mut target = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--file" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --file");
return Ok(2);
};
file = Some(value.clone());
index += 2;
}
"--agent" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --agent");
return Ok(2);
};
agent = Some(value.clone());
index += 2;
}
"--json" => {
json = true;
index += 1;
}
"--format" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --format");
return Ok(2);
};
match value.as_str() {
"json" => json = true,
"text" => json = false,
other => {
eprintln!("invalid --format: {other} (expected text or json)");
return Ok(2);
}
}
index += 2;
}
"help" | "--help" | "-h" => {
eprintln!("usage: herdr agent explain <target> [--json]");
eprintln!("usage: herdr agent explain --file PATH --agent LABEL [--json]");
return Ok(0);
}
value if value.starts_with('-') => {
eprintln!("unknown option: {value}");
return Ok(2);
}
value => {
if target.is_some() {
eprintln!("usage: herdr agent explain <target> [--json]");
return Ok(2);
}
target = Some(value.to_string());
index += 1;
}
}
}
let explain = if let Some(path) = file {
if target.is_some() {
eprintln!("usage: herdr agent explain --file PATH --agent LABEL [--json]");
return Ok(2);
}
let Some(agent_label) = agent else {
eprintln!("herdr agent explain --file requires --agent LABEL");
return Ok(2);
};
let content = std::fs::read_to_string(path)?;
crate::detect::manifest::explain_to_json_value(&crate::detect::manifest::explain_for_label(
&agent_label,
&content,
))
} else {
let Some(target) = target else {
eprintln!("usage: herdr agent explain <target> [--json]");
eprintln!("usage: herdr agent explain --file PATH --agent LABEL [--json]");
return Ok(2);
};
if agent.is_some() {
eprintln!("--agent is only valid with --file");
return Ok(2);
}
let response = super::send_request(&Request {
id: "cli:agent:explain".into(),
method: Method::AgentExplain(AgentTarget {
target: target.to_owned(),
}),
})?;
if response.get("error").is_some() {
eprintln!("{}", serde_json::to_string(&response).unwrap());
return Ok(1);
}
response["result"]["explain"].clone()
};
if json {
println!("{explain}");
} else {
print_agent_explain_text(&explain);
}
Ok(0)
}
fn print_agent_explain_text(explain: &serde_json::Value) {
println!("agent: {}", explain["agent"].as_str().unwrap_or("unknown"));
println!("state: {}", explain["state"].as_str().unwrap_or("unknown"));
println!(
"screen_detection_skipped: {}",
explain["screen_detection_skipped"]
.as_bool()
.unwrap_or(false)
);
if let Some(reason) = explain["screen_detection_skip_reason"].as_str() {
println!("screen_detection_skip_reason: {reason}");
}
println!(
"manifest: {}",
explain["manifest_source"].as_str().unwrap_or("none")
);
println!(
"manifest_version: {}",
explain["manifest_version"].as_str().unwrap_or("unknown")
);
println!(
"cached_remote_version: {}",
explain["cached_remote_version"].as_str().unwrap_or("none")
);
println!(
"local_override_shadowing_remote: {}",
explain["local_override_shadowing_remote"]
.as_bool()
.unwrap_or(false)
);
if let Some(status) = explain["remote_update_status"].as_str() {
println!("remote_update_status: {status}");
}
if let Some(error) = explain["remote_update_error"].as_str() {
println!("remote_update_error: {error}");
}
if let Some(rule) = explain["matched_rule"].as_object() {
println!(
"matched_rule: {} priority={} region={} state={}",
rule.get("id")
.and_then(|value| value.as_str())
.unwrap_or("-"),
rule.get("priority")
.and_then(|value| value.as_i64())
.unwrap_or(0),
rule.get("region")
.and_then(|value| value.as_str())
.unwrap_or("-"),
rule.get("state")
.and_then(|value| value.as_str())
.unwrap_or("unknown")
);
} else {
println!("matched_rule: none");
}
println!(
"visible: idle={} blocker={} working={}",
explain["visible_idle"].as_bool().unwrap_or(false),
explain["visible_blocker"].as_bool().unwrap_or(false),
explain["visible_working"].as_bool().unwrap_or(false)
);
if let Some(reason) = explain["fallback_reason"].as_str() {
println!("fallback_reason: {reason}");
}
if let Some(reason) = explain["skipped_update_reason"].as_str() {
println!("skipped_update_reason: {reason}");
}
if let Some(warning) = explain["warning"].as_str() {
println!("warning: {warning}");
}
if let Some(evaluated_rules) = explain["evaluated_rules"]
.as_array()
.filter(|rules| !rules.is_empty())
{
println!("evaluated_rules:");
for rule in evaluated_rules {
println!(
" {} matched={} priority={} region={} state={}",
rule["id"].as_str().unwrap_or("-"),
rule["matched"].as_bool().unwrap_or(false),
rule["priority"].as_i64().unwrap_or(0),
rule["region"].as_str().unwrap_or("-"),
rule["state"].as_str().unwrap_or("unknown")
);
let evidence = &rule["evidence"];
println!(
" matchers: contains={:?} regex={:?} line_regex={:?} all={} any={} not={}",
evidence["contains"],
evidence["regex"],
evidence["line_regex"],
evidence["all_count"].as_u64().unwrap_or(0),
evidence["any_count"].as_u64().unwrap_or(0),
evidence["not_count"].as_u64().unwrap_or(0)
);
println!(
" region: bytes={} preview={:?}",
evidence["region_bytes"].as_u64().unwrap_or(0),
evidence["region_preview"].as_str().unwrap_or("")
);
}
}
}
fn agent_start(args: &[String]) -> std::io::Result<i32> {
let Some(name) = args.first() else {
eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>");
@ -422,6 +636,8 @@ fn print_agent_help() {
eprintln!(" herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]");
eprintln!(" herdr agent attach <target> [--takeover]");
eprintln!(" herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>");
eprintln!(" herdr agent explain <target> [--json]");
eprintln!(" herdr agent explain --file PATH --agent LABEL [--json]");
eprintln!(" targets accept terminal ids, unique agent names, detected/reported agent labels, and legacy pane ids");
eprintln!(
" agent send writes literal text; use pane run when you want command text plus Enter"

View File

@ -10,6 +10,8 @@ pub(super) fn run_server_command(args: &[String]) -> std::io::Result<Option<i32>
"live-handoff" => server_live_handoff(&args[1..]).map(Some),
"--handoff-import" => Ok(None),
"reload-config" => server_reload_config(&args[1..]).map(Some),
"agent-manifests" => server_agent_manifests(&args[1..]).map(Some),
"reload-agent-manifests" => server_reload_agent_manifests(&args[1..]).map(Some),
"help" | "--help" | "-h" => {
print_server_help();
Ok(Some(0))
@ -42,6 +44,85 @@ fn server_reload_config(args: &[String]) -> std::io::Result<i32> {
})?)
}
fn server_agent_manifests(args: &[String]) -> std::io::Result<i32> {
let json = match args {
[] => false,
[flag] if flag == "--json" => true,
_ => {
eprintln!("usage: herdr server agent-manifests [--json]");
return Ok(2);
}
};
let response = super::send_request(&Request {
id: "cli:server:agent-manifests".into(),
method: Method::ServerAgentManifests(EmptyParams::default()),
})?;
if json || response.get("error").is_some() {
return super::print_response(&response);
}
print_agent_manifest_status(&response);
Ok(0)
}
fn server_reload_agent_manifests(args: &[String]) -> std::io::Result<i32> {
if !args.is_empty() {
eprintln!("usage: herdr server reload-agent-manifests");
return Ok(2);
}
super::print_response(&super::send_request(&Request {
id: "cli:server:reload-agent-manifests".into(),
method: Method::ServerReloadAgentManifests(EmptyParams::default()),
})?)
}
fn print_agent_manifest_status(response: &serde_json::Value) {
let result = &response["result"];
let last_check = result["last_check_unix"]
.as_u64()
.map(|value| value.to_string())
.unwrap_or_else(|| "never".to_string());
let last_result = result["last_result"].as_str().unwrap_or("not checked");
println!("last check: {last_check}");
println!("result: {last_result}");
println!();
let Some(manifests) = result["manifests"].as_array() else {
return;
};
for manifest in manifests {
let agent = manifest["agent"].as_str().unwrap_or("-");
let source = manifest["source_kind"].as_str().unwrap_or("-");
let active_version = manifest["active_version"].as_str().unwrap_or("-");
let remote_version = manifest["cached_remote_version"].as_str().unwrap_or("-");
let remote_result = manifest["remote_update_result"]
.as_str()
.unwrap_or("not checked");
let local_override_shadowing_remote = manifest["local_override_shadowing_remote"]
.as_bool()
.unwrap_or(false);
let marker = if local_override_shadowing_remote {
"!"
} else if manifest["remote_update_error"].as_str().is_some() {
"x"
} else {
" "
};
println!(
"{marker} {agent:<9} {source:<14} active {active_version:<14} remote {remote_version:<14} {remote_result}"
);
if let Some(error) = manifest["remote_update_error"].as_str() {
println!(" {error}");
} else if local_override_shadowing_remote {
println!(" local override shadows cached remote rules");
} else if let Some(warning) = manifest["warning"].as_str() {
println!(" {warning}");
}
}
}
fn server_live_handoff(args: &[String]) -> std::io::Result<i32> {
let Some(params) = parse_live_handoff_params(args) else {
eprintln!(
@ -105,6 +186,8 @@ fn print_server_help() {
eprintln!(" herdr server stop stop the running server via the API socket");
eprintln!(" herdr server live-handoff hand off live panes to a new local server");
eprintln!(" herdr server reload-config reload config.toml in the running server");
eprintln!(" herdr server agent-manifests [--json] show agent detection manifest status");
eprintln!(" herdr server reload-agent-manifests reload agent detection manifests in the running server");
}
#[cfg(test)]

View File

@ -1,46 +0,0 @@
use super::super::AgentState;
/// Amp (Sourcegraph) detection.
///
/// Blocked approval prompts use a shared footer with options like
/// "Approve", "Allow All for This Session", "Allow All for Every Session",
/// "Allow File for Every Session", and "Deny with feedback". The header varies
/// by approval type, for example "Invoke tool ...?", "Run this command?",
/// "Allow editing file:", or "Allow creating file:".
///
/// Working layout:
/// ```text
/// ✓ Search Map the core runtime architecture...
/// ⋯ Oracle ▼
/// ≈ Running tools... Esc to cancel
/// ```
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?")
|| lower.contains("allow editing file:")
|| lower.contains("allow creating file:")
|| lower.contains("confirm tool call");
let has_approval_actions = lower.contains("approve")
&& (lower.contains("allow all for this session")
|| lower.contains("allow all for every session")
|| lower.contains("allow file for every session")
|| lower.contains("deny with feedback"));
has_approval_actions && (has_waiting_for_approval || has_approval_header)
}

View File

@ -1,76 +0,0 @@
use super::super::AgentState;
pub(super) fn detect(content: &str) -> AgentState {
if has_visible_blocker(content) {
return AgentState::Blocked;
}
if has_antigravity_spinner(content) || has_antigravity_background_tasks(content) {
return AgentState::Working;
}
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();
let mut chars = trimmed.chars();
let Some(first) = chars.next() else {
return false;
};
if !('\u{2800}'..='\u{28FF}').contains(&first) {
return false;
}
let rest = chars
.as_str()
.trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c))
.trim_start();
status_word_is_active(rest)
})
}
fn has_antigravity_background_tasks(content: &str) -> bool {
let bottom_lines: Vec<&str> = content
.lines()
.rev()
.filter(|line| !line.trim().is_empty())
.take(5)
.collect();
bottom_lines.into_iter().any(|line| {
let line = line.trim().to_lowercase();
line.contains("/tasks") && antigravity_task_count(&line).is_some_and(|count| count > 0)
})
}
fn antigravity_task_count(line: &str) -> Option<u32> {
for marker in [" task(s)", " tasks", " task"] {
let Some((before, _)) = line.split_once(marker) else {
continue;
};
let raw_count = before.split_whitespace().last()?.trim_matches(|c| c == '·');
if let Ok(count) = raw_count.parse() {
return Some(count);
}
}
None
}
fn status_word_is_active(rest: &str) -> bool {
let Some(word) = rest.split_whitespace().next() else {
return false;
};
word.trim_end_matches(|c: char| !c.is_alphabetic())
.to_ascii_lowercase()
.ends_with("ing")
}

View File

@ -1,434 +0,0 @@
use super::super::{has_confirmation_prompt, has_selection_prompt, AgentState};
/// Claude Code detection. The most complex — it has a structured prompt box UI.
///
/// Screen layout:
/// ```text
/// (agent output / tool results)
/// ───────────────────────── (top border)
/// _ (prompt line)
/// ───────────────────────── (bottom border)
/// ```
pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
if has_live_blocked_form(content) {
return AgentState::Blocked;
}
if has_dynamic_workflow_prompt(&lower) {
return AgentState::Blocked;
}
if has_working_chrome(content) {
return AgentState::Working;
}
if !has_prompt_box(content) && has_claude_blocked_prompt(content, &lower) {
return AgentState::Blocked;
}
AgentState::Idle
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
if has_live_input_prompt_box(content) {
return false;
}
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")
|| lower.contains("bash(")
|| lower.contains("contains expansion")
|| lower.contains("tab to amend")
|| lower.contains("ctrl+e to explain"))
}
pub(in crate::detect) fn has_idle_recap_notice(content: &str) -> bool {
if !has_prompt_box(content) || has_visible_blocker(content) {
return false;
}
let above_prompt = content_above_prompt_box(content);
let bottom_lines = bottom_non_empty_lines(above_prompt, 8);
let Some(last_line) = bottom_lines.last() else {
return false;
};
if !last_line
.to_ascii_lowercase()
.contains("(disable recaps in /config)")
{
return false;
}
let bottom = normalize_lines(&bottom_lines).to_ascii_lowercase();
bottom.contains("※ recap:")
&& !bottom.contains("esc to interrupt")
&& !bottom.contains("ctrl+c to interrupt")
}
pub(super) fn has_working_chrome(content: &str) -> bool {
let above = content_above_prompt_box(content);
let above_lower = above.to_lowercase();
above_lower.contains("esc to interrupt")
|| above_lower.contains("ctrl+c to interrupt")
|| has_running_status_line(above)
|| has_spinner_activity(above)
}
pub(super) fn is_transcript_viewer(content: &str) -> bool {
let bottom_lines = bottom_non_empty_lines(content, 3);
let Some(last_line) = bottom_lines.last() else {
return false;
};
let bottom_text = normalize_lines(&bottom_lines);
bottom_text.contains("showing detailed transcript")
&& bottom_text.contains("ctrl+o to toggle")
&& (bottom_text.contains("ctrl+e to show all")
|| bottom_text.contains("ctrl+e to collapse"))
&& transcript_control_tail(last_line)
}
pub(super) fn has_prompt_box(content: &str) -> bool {
prompt_box_body_lines(content)
.is_some_and(|lines| lines.iter().any(|line| line.trim_start().starts_with('')))
}
fn has_live_input_prompt_box(content: &str) -> bool {
prompt_box_body_lines(content).is_some_and(|lines| {
let non_empty: Vec<&str> = lines
.iter()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.collect();
let Some(first) = non_empty.first() else {
return false;
};
first.trim_start().starts_with('')
&& !non_empty
.iter()
.any(|line| claude_prompt_box_line_is_selector_chrome(line))
})
}
fn claude_prompt_box_line_is_selector_chrome(line: &str) -> bool {
let trimmed = line.trim().trim_start_matches('').trim_start();
let lower = trimmed.to_ascii_lowercase();
lower.contains("enter to select")
|| lower.contains("enter to confirm")
|| lower.contains("enter to submit")
|| lower.contains("esc to cancel")
|| lower.contains("tab/arrow")
|| lower.contains("arrow keys")
|| lower.contains("↑/↓")
|| lower.contains("↑↓")
|| lower.contains("ctrl+g to edit")
|| lower.contains("ctrl+e to explain")
|| trimmed
.split_once('.')
.is_some_and(|(prefix, _)| prefix.trim().parse::<u32>().is_ok())
}
fn prompt_box_body_lines(content: &str) -> Option<Vec<&str>> {
let lines: Vec<&str> = content.lines().collect();
let top_border_index = claude_prompt_box_top_border_index(&lines)?;
Some(
lines[top_border_index + 1..]
.iter()
.take_while(|line| !is_horizontal_rule(line))
.copied()
.collect(),
)
}
/// Claude uses the same generic Select and Dialog widgets for both
/// permission flows and ordinary slash/settings menus. Match only the
/// permission and interview prompts that actually need user input.
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")
|| lower_content.contains("ctrl+e to explain")
|| lower_content.contains("review your answers")
|| lower_content.contains("skip interview and plan immediately")
|| (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| {
let lower = line.to_lowercase();
lower.contains("enter to select")
&& lower.contains("esc to cancel")
&& (lower.contains("tab/arrow keys to navigate")
|| lower.contains("arrow keys to navigate")
|| lower.contains("arrows to navigate")
|| lower.contains("↑/↓ to navigate")
|| lower.contains("↑↓ to navigate"))
})
}
fn has_running_status_line(content_above_prompt: &str) -> bool {
let Some(line) = content_above_prompt
.lines()
.rev()
.find(|line| !line.trim().is_empty())
else {
return false;
};
is_background_agent_wait_line(line) || is_still_running_status_line(line)
}
fn is_background_agent_wait_line(line: &str) -> bool {
let mut text = line.trim();
if !text.starts_with("Waiting for ") && !text.starts_with("waiting for ") {
let mut chars = text.chars();
let Some(first) = chars.next() else {
return false;
};
if first.is_alphanumeric() {
return false;
}
text = chars.as_str().trim_start();
}
let lower = text.to_ascii_lowercase();
let Some(rest) = lower.strip_prefix("waiting for ") else {
return false;
};
let Some((count, rest)) = rest.split_once(' ') else {
return false;
};
if count.parse::<u32>().ok().is_none_or(|count| count == 0) {
return false;
}
rest == "background agent to finish" || rest == "background agents to finish"
}
fn is_still_running_status_line(line: &str) -> bool {
let lower = line.to_ascii_lowercase();
let words: Vec<&str> = lower.split_whitespace().collect();
for (index, word) in words.iter().enumerate() {
let Ok(count) = word.parse::<u32>() else {
continue;
};
if count == 0 {
continue;
}
if matches!(
words.get(index + 1..index + 4),
Some(["shell" | "shells", "still", "running"])
) {
return true;
}
if matches!(
words.get(index + 1..index + 5),
Some(["local", "agent" | "agents", "still", "running"])
) {
return true;
}
}
false
}
fn has_claude_yes_no_choice(content: &str) -> bool {
content.lines().any(|line| {
let trimmed = line
.trim()
.trim_start_matches('')
.trim_start()
.to_lowercase();
trimmed == "yes"
|| trimmed == "no"
|| trimmed.starts_with("1. yes")
|| trimmed.starts_with("2. no")
|| trimmed.starts_with("yes, and ")
|| trimmed.starts_with("no, and tell claude")
})
}
/// Claude Code spinner characters + activity label.
/// The verb changes frequently ("Processing…", "Pouncing…", etc.), so rely
/// on the spinner glyph + trailing ellipsis rather than specific wording.
/// Include Claude's narrow-pane middle-dot frame too.
pub(in crate::detect) fn has_spinner_activity(content: &str) -> bool {
const SPINNER_CHARS: &str = "·✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆";
for line in content.lines() {
let trimmed = line.trim();
let mut chars = trimmed.chars();
if let Some(first) = chars.next() {
if SPINNER_CHARS.contains(first) {
let rest: String = chars.collect();
if rest.starts_with(' ')
&& rest.contains('\u{2026}')
&& rest.chars().any(|c| c.is_alphanumeric())
{
return true;
}
}
}
}
false
}
/// Extract content above Claude's prompt box.
/// The prompt box is two ─── border lines with between them.
pub(in crate::detect) fn content_above_prompt_box(content: &str) -> &str {
let lines: Vec<&str> = content.lines().collect();
if let Some(i) = claude_prompt_box_top_border_index(&lines) {
let byte_offset: usize = lines[..i].iter().map(|l| l.len() + 1).sum();
return &content[..byte_offset.min(content.len())];
}
// No prompt box found, return all content
content
}
fn content_after_last_horizontal_rule(content: &str) -> &str {
let mut last_rule_end = 0usize;
let mut offset = 0usize;
for line in content.lines() {
let next_offset = offset + line.len() + 1;
if is_horizontal_rule(line) {
last_rule_end = next_offset.min(content.len());
}
offset = next_offset;
}
&content[last_rule_end..]
}
fn claude_prompt_box_top_border_index(lines: &[&str]) -> Option<usize> {
let mut border_count = 0;
for i in (0..lines.len()).rev() {
if is_horizontal_rule(lines[i]) {
border_count += 1;
if border_count == 2 {
return Some(i);
}
}
}
None
}
fn is_horizontal_rule(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() {
return false;
}
let rule_chars = trimmed.chars().take_while(|&c| c == '─').count();
if rule_chars == 0 {
return false;
}
let rule_bytes = trimmed
.char_indices()
.nth(rule_chars)
.map(|(index, _)| index)
.unwrap_or(trimmed.len());
let suffix = trimmed[rule_bytes..].trim_start();
suffix.is_empty() || rule_chars >= 3
}
fn bottom_non_empty_lines(content: &str, max_lines: usize) -> Vec<&str> {
let mut lines: Vec<&str> = content
.lines()
.rev()
.filter(|line| !line.trim().is_empty())
.take(max_lines)
.collect();
lines.reverse();
lines
}
fn normalize_lines(lines: &[&str]) -> String {
lines
.iter()
.flat_map(|line| line.split_whitespace())
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
fn transcript_control_tail(line: &str) -> bool {
let lower = line.to_lowercase();
lower.contains("ctrl+e")
|| lower.contains("show all")
|| lower.contains("collapse")
|| lower.contains("verbose")
}
#[cfg(test)]
mod tests {
use super::*;
fn prompt_box_below(content_above_prompt: &str) -> String {
format!(
"{content_above_prompt}\n────────────────────────────────\n \n────────────────────────────────\n"
)
}
#[test]
fn shell_still_running_status_line_is_working() {
let content = prompt_box_below(
"● Started. I'll tell you when it finishes.\n\n✻ Crunched for 7s · 1 shell still running",
);
assert_eq!(detect(&content), AgentState::Working);
assert!(has_working_chrome(&content));
}
#[test]
fn local_agent_still_running_status_line_is_working() {
let content = prompt_box_below(
"● Hey. What do you want to work on?\n\n✻ Worked for 4s · 2 local agents still running",
);
assert_eq!(detect(&content), AgentState::Working);
assert!(has_working_chrome(&content));
}
#[test]
fn lower_agent_picker_shell_count_is_not_working_chrome() {
let content = prompt_box_below(" ~/P/herdr ⎇ master ▱▱▱▱▱ 0%\n 1 shell · ← for agents");
assert_eq!(detect(&content), AgentState::Idle);
assert!(!has_working_chrome(&content));
}
#[test]
fn stale_shell_running_line_above_newer_output_is_not_working_chrome() {
let content = prompt_box_below(
"● Started. I'll tell you when it finishes.\n\n✻ Crunched for 7s · 1 shell still running\n\n● hi",
);
assert_eq!(detect(&content), AgentState::Idle);
assert!(!has_working_chrome(&content));
}
}

View File

@ -1,19 +0,0 @@
use super::super::AgentState;
pub(super) fn detect(content: &str) -> AgentState {
// Blocked
if has_visible_blocker(content) {
return AgentState::Blocked;
}
// 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

@ -1,258 +0,0 @@
use super::super::{has_confirmation_prompt, has_interrupt_pattern, AgentState};
pub(super) fn detect(content: &str) -> AgentState {
// Strong blocked patterns are structural Codex UI chrome, so they can win
// even when the prompt region is visible.
if has_codex_strong_blocked_prompt(content) {
return AgentState::Blocked;
}
let lower = content.to_lowercase();
// Working
if has_codex_working_status_at_current_prompt(content) {
return AgentState::Working;
}
// Weak blocked patterns are too broad to become visible blockers, but the
// legacy state detector still reports them as blocked for compatibility.
if has_codex_weak_blocked_prompt(&lower) {
return AgentState::Blocked;
}
// Fallback working signals for narrow captures where the footer scrolled
// out or the working row is the only Codex chrome visible.
if has_interrupt_pattern(&lower) || has_codex_working_header(content) {
return AgentState::Working;
}
AgentState::Idle
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
has_codex_strong_blocked_prompt(content)
}
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))
}
pub(super) fn is_transcript_viewer(content: &str) -> bool {
let bottom_lines = bottom_non_empty_lines(content, 3);
let Some(last_line) = bottom_lines.last() else {
return false;
};
let bottom_text = normalize_lines(&bottom_lines);
bottom_text.contains("↑/↓ to scroll")
&& bottom_text.contains("pgup/pgdn to page")
&& bottom_text.contains("home/end to jump")
&& bottom_text.contains("q to quit")
&& has_codex_edit_prev_controls(&bottom_text)
&& transcript_control_tail(last_line)
}
fn has_codex_edit_prev_controls(bottom_text: &str) -> bool {
bottom_text.contains("esc to edit prev") || bottom_text.contains("esc/← to edit prev")
}
fn has_codex_visible_working_without_prompt(content: &str) -> bool {
let mut recent_lines = content.lines().rev().filter(|line| !line.trim().is_empty());
let Some(last_line) = recent_lines.next() else {
return false;
};
if codex_live_working_line(last_line) {
return true;
}
codex_status_detail_line(last_line)
&& recent_lines
.take(4)
.find(|line| codex_block_marker_line(line))
.is_some_and(codex_live_working_line)
}
fn has_codex_strong_blocked_prompt(content: &str) -> bool {
let lines: Vec<&str> = content.lines().collect();
let live_region = lines
.iter()
.rposition(|line| codex_prompt_line(line))
.map(|prompt_index| lines[prompt_index + 1..].join("\n"))
.unwrap_or_else(|| content.to_string());
let lower_content = live_region.to_lowercase();
lower_content.contains("press enter to confirm or esc to cancel")
|| lower_content.contains("enter to submit answer")
|| lower_content.contains("enter to submit all")
|| lower_content.contains("allow command?")
}
fn has_codex_weak_blocked_prompt(lower_content: &str) -> bool {
lower_content.contains("[y/n]")
|| lower_content.contains("yes (y)")
|| has_confirmation_prompt(lower_content)
}
fn has_codex_live_working_at_current_prompt(content: &str) -> bool {
codex_last_block_marker_before_current_prompt(content).is_some_and(codex_live_working_line)
}
fn has_codex_working_status_at_current_prompt(content: &str) -> bool {
codex_last_block_marker_before_current_prompt(content).is_some_and(codex_working_status_line)
}
fn codex_last_block_marker_before_current_prompt(content: &str) -> Option<&str> {
let (lines, prompt_index) = codex_current_prompt_region(content)?;
lines[..prompt_index]
.iter()
.rev()
.find(|line| codex_block_marker_line(line))
.copied()
}
fn has_codex_working_header(content: &str) -> bool {
content.lines().any(codex_working_status_line)
}
fn codex_live_working_line(line: &str) -> bool {
if codex_queued_input_header_line(line) {
return true;
}
let trimmed = line.trim_start();
let lower = trimmed.to_lowercase();
codex_working_status_line(line)
&& (trimmed.contains("Waiting for background terminal")
|| has_codex_status_interrupt_hint(&lower)
|| lower.contains("background terminal running")
|| lower.contains("/ps to view")
|| lower.contains("/stop to close"))
}
fn codex_working_status_line(line: &str) -> bool {
if codex_queued_input_header_line(line) {
return true;
}
let trimmed = line.trim_start();
let lower = trimmed.to_lowercase();
trimmed.starts_with('•')
&& (trimmed.contains("Working (")
|| trimmed.contains("Waiting for background terminal (")
|| has_codex_status_interrupt_hint(&lower)
|| lower.contains("reviewing approval request (")
|| (lower.contains("reviewing ") && lower.contains(" approval requests ("))
|| trimmed.contains("Booting MCP server:"))
}
fn has_codex_status_interrupt_hint(lower_line: &str) -> bool {
let Some((before_escape, after_escape)) = lower_line.split_once(" • esc") else {
return false;
};
has_codex_status_elapsed(before_escape) && has_codex_status_escape_suffix(after_escape)
}
fn has_codex_status_elapsed(before_escape: &str) -> bool {
let Some((_, elapsed)) = before_escape.rsplit_once('(') else {
return false;
};
let parts: Vec<&str> = elapsed.split_whitespace().collect();
(1..=3).contains(&parts.len())
&& parts.iter().all(|part| {
part.len() >= 2
&& matches!(part.as_bytes().last(), Some(b'h' | b'm' | b's'))
&& part[..part.len() - 1].chars().all(|ch| ch.is_ascii_digit())
})
}
fn has_codex_status_escape_suffix(after_escape: &str) -> bool {
let suffix = after_escape.trim_start();
if suffix.starts_with('…') || suffix.starts_with("to interrupt") {
return true;
}
let Some(rest) = suffix.strip_prefix("to ") else {
return false;
};
if rest.starts_with('…') {
return true;
}
rest.split_once('…')
.map(|(fragment, _)| !fragment.is_empty() && "interrupt".starts_with(fragment.trim_end()))
.unwrap_or(false)
}
fn has_codex_current_prompt(content: &str) -> bool {
codex_current_prompt_region(content).is_some()
}
fn codex_current_prompt_region(content: &str) -> Option<(Vec<&str>, usize)> {
let lines: Vec<&str> = content.lines().collect();
let prompt_index = lines.iter().rposition(|line| codex_prompt_line(line))?;
if lines[prompt_index + 1..]
.iter()
.any(|line| codex_block_marker_line(line))
{
return None;
}
Some((lines, prompt_index))
}
fn codex_prompt_line(line: &str) -> bool {
line == "" || line.starts_with(" ")
}
fn codex_block_marker_line(line: &str) -> bool {
line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓')
}
fn codex_status_detail_line(line: &str) -> bool {
line.trim_start().starts_with('└')
}
fn codex_queued_input_header_line(line: &str) -> bool {
let trimmed = line.trim_start();
if !trimmed.starts_with('•') {
return false;
}
let lower = trimmed.to_lowercase();
lower.starts_with("• queued follow-up inputs")
|| lower.starts_with("• messages to be submitted after next tool call")
}
fn bottom_non_empty_lines(content: &str, max_lines: usize) -> Vec<&str> {
let mut lines: Vec<&str> = content
.lines()
.rev()
.filter(|line| !line.trim().is_empty())
.take(max_lines)
.collect();
lines.reverse();
lines
}
fn normalize_lines(lines: &[&str]) -> String {
lines
.iter()
.flat_map(|line| line.split_whitespace())
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
fn transcript_control_tail(line: &str) -> bool {
let lower = line.to_lowercase();
lower.contains("q to quit")
|| lower.contains("esc to edit")
|| lower.contains("esc/← to edit")
|| lower.contains("edit message")
}

View File

@ -1,79 +0,0 @@
use super::super::AgentState;
pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
// Blocked
if has_visible_blocker(content) {
return AgentState::Blocked;
}
// Working
if lower.contains("ctrl+c to stop") {
return AgentState::Working;
}
if has_cursor_spinner(content) {
return AgentState::Working;
}
AgentState::Idle
}
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;
}
if lower.contains("(y) (enter)")
|| lower.contains("keep (n)")
|| lower.contains("skip (esc or n)")
{
return true;
}
content.lines().any(|line| {
let line = line.trim().to_lowercase();
let has_yes_action = line.contains("(y)");
has_yes_action
&& (line.contains("allow")
|| line.contains("run (once)")
|| line.contains("→ run")
|| line.starts_with("run "))
})
}
/// Cursor status line: spinner glyphs followed by a live action label.
pub(in crate::detect) fn has_cursor_spinner(content: &str) -> bool {
content.lines().any(|line| {
let trimmed = line.trim_start();
let mut chars = trimmed.chars();
let Some(first) = chars.next() else {
return false;
};
let rest = chars.as_str().trim_start();
if matches!(first, '⬡' | '⬢') {
return cursor_status_word_is_active(rest);
}
if ('\u{2800}'..='\u{28FF}').contains(&first) {
let rest = rest.trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c));
return cursor_status_word_is_active(rest.trim_start());
}
false
})
}
fn cursor_status_word_is_active(rest: &str) -> bool {
let Some(word) = rest.split_whitespace().next() else {
return false;
};
word.trim_end_matches(|c: char| !c.is_alphabetic())
.to_ascii_lowercase()
.ends_with("ing")
}

View File

@ -1,39 +0,0 @@
use super::super::{has_braille_spinner, AgentState};
/// Droid detection.
///
/// Working: braille spinner line (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) + "Thinking..." + "(Press ESC to stop)"
/// Blocked: EXECUTE prompt with selection box ("Yes, allow" / "No, cancel") +
/// "Use ↑↓ to navigate, Enter to select"
pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
if has_visible_blocker(content) {
return AgentState::Blocked;
}
// Working: braille spinner character at start of a line + "Thinking..."
// The braille chars (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) are very specific — won't appear in normal content
if has_braille_spinner(content) && lower.contains("esc to stop") {
return AgentState::Working;
}
// Fallback: "ESC to stop" alone is still a strong signal (it's UI chrome)
if lower.contains("esc to stop") {
return AgentState::Working;
}
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,31 +0,0 @@
use super::super::AgentState;
pub(super) fn detect(content: &str) -> AgentState {
if has_visible_blocker(content) {
return AgentState::Blocked;
}
let lower = content.to_lowercase();
// Working
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_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

@ -1,28 +0,0 @@
use super::super::AgentState;
pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
// Blocked
if has_visible_blocker(content) {
return AgentState::Blocked;
}
// Working
if lower.contains("esc to cancel")
|| lower.contains("esc cancel")
|| lower.contains("esc again to cancel")
{
return AgentState::Working;
}
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

@ -1,37 +0,0 @@
use super::super::{has_braille_spinner, AgentState};
/// Grok Build detection.
///
/// Blocked permission prompts display a whitelist scope selector with choices
/// like "Yes, proceed" and "No, reject". Working turns show a braille spinner
/// 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 {
if has_visible_blocker(content) {
return AgentState::Blocked;
}
let lower = content.to_lowercase();
if has_braille_spinner(content)
&& (lower.contains("waiting")
|| lower.contains("run ")
|| lower.contains("read ")
|| lower.contains("search ")
|| lower.contains("list "))
{
return AgentState::Working;
}
if lower.contains("ctrl+c:cancel") && lower.contains("ctrl+enter:interject") {
return AgentState::Working;
}
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

@ -1,30 +0,0 @@
use super::super::AgentState;
/// Hermes Agent detection.
///
/// Hermes shows a bottom status bar while turns are active and modal approval
/// 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 {
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

@ -1,13 +0,0 @@
use super::super::AgentState;
pub(super) fn detect(content: &str) -> AgentState {
if content.to_lowercase().contains("esc interrupt") {
return AgentState::Working;
}
super::opencode::detect(content)
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
super::opencode::has_visible_blocker(content)
}

View File

@ -1,97 +0,0 @@
use super::super::AgentState;
pub(super) fn detect(content: &str) -> AgentState {
if has_kimi_blocked_prompt(content) {
return AgentState::Blocked;
}
if has_kimi_working_status(content) {
return AgentState::Working;
}
AgentState::Idle
}
pub(super) fn has_visible_blocker(content: &str) -> bool {
has_current_approval_panel(content) || has_question_panel(content)
}
pub(super) fn has_visible_working(content: &str) -> bool {
has_kimi_working_status(content)
}
fn has_kimi_blocked_prompt(content: &str) -> bool {
if has_visible_blocker(content) {
return true;
}
let lower = content.to_lowercase();
lower.contains("requesting approval")
&& (lower.contains("approve once") || lower.contains("approve for this session"))
&& lower.contains("reject")
&& (lower.contains("1/2/3/4 choose") || lower.contains("↵ confirm"))
}
fn has_kimi_working_status(content: &str) -> bool {
content.lines().any(|line| {
let trimmed = line.trim();
if matches!(
trimmed,
"🌕" | "🌖" | "🌗" | "🌘" | "🌑" | "🌒" | "🌓" | "🌔"
) {
return true;
}
let mut chars = trimmed.chars();
let Some(first) = chars.next() else {
return false;
};
if !('\u{2800}'..='\u{28FF}').contains(&first) {
return false;
}
let rest = chars
.as_str()
.trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c))
.trim_start()
.to_lowercase();
rest.starts_with("thinking...")
|| rest.starts_with("working...")
|| rest.starts_with("using ")
})
}
fn has_current_approval_panel(content: &str) -> bool {
let lower = content.to_lowercase();
has_approval_title(&lower)
&& has_numeric_choose_hint(&lower)
&& lower.contains("↵ confirm")
&& (lower.contains("approve") || lower.contains("reject") || lower.contains("revise"))
}
fn has_approval_title(lower_content: &str) -> bool {
lower_content.contains("run this command?")
|| lower_content.contains("write this file?")
|| lower_content.contains("apply these edits?")
|| lower_content.contains("stop this task?")
|| lower_content.contains("ready to build with this plan?")
|| lower_content.lines().any(|line| {
let trimmed = line.trim_start_matches(|c: char| c == '▶' || c.is_whitespace());
trimmed.starts_with("approve ") && trimmed.ends_with('?')
})
}
fn has_question_panel(content: &str) -> bool {
let lower = content.to_lowercase();
content.lines().any(|line| line.trim() == "question")
&& content
.lines()
.any(|line| line.trim_start().starts_with("? "))
&& lower.contains("↑↓ select")
&& (lower.contains("↵ choose") || lower.contains("↵ toggle") || lower.contains("↵ save"))
&& lower.contains("esc cancel")
}
fn has_numeric_choose_hint(lower_content: &str) -> bool {
lower_content.contains(" choose") && lower_content.contains('1') && lower_content.contains('2')
}

View File

@ -1,60 +0,0 @@
use super::super::AgentState;
/// Kiro CLI detection.
///
/// Kiro exposes reliable working and idle terminal markers. Tool approval
/// prompts render with stable approval wording and an action menu.
pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
if has_visible_blocker(content) {
return AgentState::Blocked;
}
if lower.contains("kiro is working")
|| (lower.contains("esc to cancel") && has_kiro_tool_spinner(content))
{
return AgentState::Working;
}
AgentState::Idle
}
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 {
let has_approval_request = lower_content.contains("requires approval");
let has_approval_actions = lower_content.contains("yes, single permission")
|| lower_content.contains("trust, always allow")
|| lower_content.contains("no (tab to edit)")
|| lower_content.contains("esc to close");
has_approval_request && has_approval_actions
}
fn has_subagent_approval_prompt(lower_content: &str) -> bool {
let has_approval_request = (lower_content.contains("tool approval")
|| lower_content.contains("tool approvals"))
&& lower_content.contains("pending from subagents");
let has_approval_actions = lower_content.contains("approve all pending")
|| lower_content.contains("configure individually")
|| lower_content.contains("exit (cancel subagents)");
has_approval_request && has_approval_actions
}
fn has_kiro_tool_spinner(content: &str) -> bool {
content.lines().any(|line| {
let trimmed = line.trim_start();
let mut chars = trimmed.chars();
let Some(first) = chars.next() else {
return false;
};
if !matches!(first, '◔' | '◑' | '◕' | '●') {
return false;
}
let rest = chars.as_str().trim_start();
rest.chars().next().is_some_and(char::is_alphabetic)
})
}

View File

@ -1,105 +0,0 @@
pub(super) mod amp;
pub(super) mod antigravity;
pub(super) mod claude_code;
pub(super) mod cline;
pub(super) mod codex;
pub(super) mod cursor;
pub(super) mod droid;
pub(super) mod gemini;
pub(super) mod github_copilot;
pub(super) mod grok;
pub(super) mod hermes;
pub(super) mod kilo;
pub(super) mod kimi;
pub(super) mod kiro;
pub(super) mod opencode;
pub(super) mod pi;
pub(super) mod qodercli;
use super::{Agent, AgentDetection, AgentState};
pub(super) fn detect(agent: Agent, screen_content: &str) -> AgentDetection {
let state = match agent {
Agent::Pi => pi::detect(screen_content),
Agent::Claude => claude_code::detect(screen_content),
Agent::Codex => codex::detect(screen_content),
Agent::Gemini => gemini::detect(screen_content),
Agent::Cursor => cursor::detect(screen_content),
Agent::Antigravity => antigravity::detect(screen_content),
Agent::Cline => cline::detect(screen_content),
Agent::OpenCode => opencode::detect(screen_content),
Agent::GithubCopilot => github_copilot::detect(screen_content),
Agent::Kimi => kimi::detect(screen_content),
Agent::Kiro => kiro::detect(screen_content),
Agent::Droid => droid::detect(screen_content),
Agent::Amp => amp::detect(screen_content),
Agent::Grok => grok::detect(screen_content),
Agent::Hermes => hermes::detect(screen_content),
Agent::Kilo => kilo::detect(screen_content),
Agent::Qodercli => qodercli::detect(screen_content),
};
let skip_state_update = should_skip_state_update(agent, screen_content);
if skip_state_update {
return AgentDetection {
state,
skip_state_update: true,
visible_blocker: false,
visible_working: false,
};
}
AgentDetection {
state,
skip_state_update: false,
visible_blocker: has_visible_blocker(agent, screen_content),
visible_working: has_visible_working(agent, screen_content, state),
}
}
pub(super) fn should_skip_state_update(agent: Agent, content: &str) -> bool {
match agent {
Agent::Claude => claude_code::is_transcript_viewer(content),
Agent::Codex => codex::is_transcript_viewer(content),
_ => false,
}
}
fn has_visible_blocker(agent: Agent, content: &str) -> bool {
match agent {
// Strong visible blockers are opt-in because this flag can override
// hook authority. Plain blocked heuristics remain valid fallback state,
// but they must not become hook overrides unless the current UI chrome
// 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),
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,
}
}
fn has_visible_working(agent: Agent, content: &str, state: AgentState) -> bool {
if state != AgentState::Working {
return false;
}
match agent {
Agent::Claude => claude_code::has_working_chrome(content),
Agent::Codex => codex::has_visible_working(content),
Agent::Kimi => kimi::has_visible_working(content),
_ => false,
}
}

View File

@ -1,58 +0,0 @@
use super::super::{has_interrupt_pattern, AgentState};
pub(super) fn detect(content: &str) -> AgentState {
// Blocked
if has_visible_blocker(content) {
return AgentState::Blocked;
}
// Working
if has_interrupt_pattern(&content.to_lowercase())
|| has_opencode_interrupt_footer(content)
|| has_opencode_progress_run(content)
{
return AgentState::Working;
}
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")
|| lower.contains("enter submit")
|| lower.contains("enter toggle");
let has_question_nav = content.contains("↑↓ select") || content.contains("⇆ tab");
lower.contains("esc dismiss") && has_enter_action && has_question_nav
}
fn has_opencode_interrupt_footer(content: &str) -> bool {
content.lines().any(|line| {
let lower = line.to_lowercase();
if !(lower.contains("esc interrupt") || lower.contains("esc again to interrupt")) {
return false;
}
lower.contains("opencode")
})
}
fn has_opencode_progress_run(line: &str) -> bool {
let mut run = 0usize;
for ch in line.chars() {
if matches!(ch, '■' | '⬝') {
run += 1;
if run >= 4 {
return true;
}
} else {
run = 0;
}
}
false
}

View File

@ -1,9 +0,0 @@
use super::super::AgentState;
pub(super) fn detect(content: &str) -> AgentState {
// pi shows "Working..." when the agent is processing
if content.contains("Working...") {
return AgentState::Working;
}
AgentState::Idle
}

View File

@ -1,86 +0,0 @@
use super::super::AgentState;
/// Qodercli detection.
///
/// Qodercli is a Node.js coding-agent CLI. It surfaces a confirmation prompt
/// while awaiting tool approval and a braille spinner while working.
pub(super) fn detect(content: &str) -> AgentState {
let lower = content.to_lowercase();
if has_visible_blocker(content) {
return AgentState::Blocked;
}
// Working: explicit "(esc to cancel, …)" hint or an active spinner row.
if has_qodercli_working_hint(&lower) || has_qodercli_spinner_row(content) {
return AgentState::Working;
}
AgentState::Idle
}
/// Working hints qodercli prints alongside the spinner while the model is
/// responding. The "(esc to cancel, …)" suffix is unique to qodercli's loading
/// indicator and survives even when the spinner glyph is masked (e.g. by
/// a hook icon).
fn has_qodercli_working_hint(lower_content: &str) -> bool {
lower_content.contains("(esc to cancel,")
}
/// Strict spinner-row detection for qodercli.
///
/// Matches a line whose first non-whitespace glyph is a braille pattern
/// (U+2800U+28FF, the cli-spinners "dots" set qodercli renders), followed by
/// a space and at least one alphabetic character on the same line. This avoids
/// flagging the pane as Working when the scrollback merely contains a stale
/// braille glyph from an earlier frame.
fn has_qodercli_spinner_row(content: &str) -> bool {
for line in content.lines() {
let trimmed = line.trim_start();
let mut chars = trimmed.chars();
let Some(first) = chars.next() else {
continue;
};
if !('\u{2800}'..='\u{28FF}').contains(&first) {
continue;
}
let rest: String = chars.collect();
if rest.starts_with(' ') && rest.chars().any(|c| c.is_alphabetic()) {
return true;
}
}
false
}
/// Blocked patterns specific to qodercli.
///
/// Mirrors the helper structure used by Claude blocked prompt matching so the
/// pattern surface stays a single, easy-to-extend list.
///
/// Covered states:
/// * Tool-call confirmation banners ("Waiting for user confirmation",
/// "Awaiting approval").
/// * The "Permission Required / Allow once or always?" approval dialog.
/// * The `ask-user` tool's interactive prompt. "Asking User" is the dialog's
/// stable BaseTabDialog title and covers every form (single-select,
/// multi-select, free-form input, review tab). The "Enter your response"
/// placeholder and "Review your answers:" review heading are kept as
/// 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.
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")
|| lower_content.contains("enter your response")
|| lower_content.contains("review your answers:")
|| lower_content.contains("shell awaiting input")
}

1411
src/detect/manifest.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,498 @@
use super::*;
fn remote_manifest(version: &str, state: &str, contains: &str) -> String {
format!(
r#"
id = "codex"
version = "{version}"
min_engine_version = 1
updated_at = "2026-06-10T12:00:00Z"
[[rules]]
id = "test"
state = "{state}"
contains = ["{contains}"]
"#
)
}
fn local_manifest(state: &str, contains: &str) -> String {
format!(
r#"
id = "codex"
[[rules]]
id = "test"
state = "{state}"
contains = ["{contains}"]
"#
)
}
fn rules_manifest(rules: &str) -> String {
format!(
r#"
id = "codex"
{rules}
"#
)
}
fn with_manifest_dirs<T>(name: &str, f: impl FnOnce() -> T) -> T {
let _guard = crate::config::test_config_env_lock().lock().unwrap();
let old_config = std::env::var_os("XDG_CONFIG_HOME");
let old_state = std::env::var_os("XDG_STATE_HOME");
let base = std::env::temp_dir().join(format!(
"herdr-manifest-loader-{name}-{}",
std::process::id()
));
let config_dir = base.join("config");
let state_dir = base.join("state");
let _ = std::fs::remove_dir_all(&base);
std::env::set_var("XDG_CONFIG_HOME", &config_dir);
std::env::set_var("XDG_STATE_HOME", &state_dir);
reload_manifests();
let result = f();
match old_config {
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
match old_state {
Some(value) => std::env::set_var("XDG_STATE_HOME", value),
None => std::env::remove_var("XDG_STATE_HOME"),
}
reload_manifests();
let _ = std::fs::remove_dir_all(&base);
result
}
fn write_remote_codex(content: &str) {
let path = crate::detect::manifest_update::remote_manifest_path(Agent::Codex);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
reload_manifests();
}
fn write_remote_codex_without_reload(content: &str) {
let path = crate::detect::manifest_update::remote_manifest_path(Agent::Codex);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
}
fn write_local_codex(content: &str) {
let path = override_path(Agent::Codex).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
reload_manifests();
}
#[test]
fn known_agent_no_match_defaults_to_idle_fallback() {
let explain = explain(Agent::Codex, "ordinary prompt text");
assert_eq!(explain.state, AgentState::Idle);
assert!(!explain.visible_idle);
assert_eq!(
explain.fallback_reason.as_deref(),
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
);
}
#[test]
fn rule_semantics_apply_gates_priority_and_line_regex() {
with_manifest_dirs("rule-semantics", || {
write_local_codex(&rules_manifest(
r#"
[[rules]]
id = "low_contains"
state = "idle"
priority = 1
contains = ["match"]
[[rules]]
id = "high_nested_gates"
state = "working"
priority = 10
contains = ["match"]
all = [
{ any = [{ regex = ["w[io]n"] }, { contains = ["fallback"] }] },
]
not = [
{ contains = ["blocked"] },
]
[[rules]]
id = "line_regex"
state = "blocked"
priority = 20
line_regex = ["^exact line$"]
"#,
));
let high = explain(Agent::Codex, "match win");
assert_eq!(high.state, AgentState::Working);
assert_eq!(
high.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("high_nested_gates")
);
let not_gate = explain(Agent::Codex, "match win blocked");
assert_eq!(not_gate.state, AgentState::Idle);
assert_eq!(
not_gate.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("low_contains")
);
let line = explain(Agent::Codex, "before\nexact line\nafter");
assert_eq!(line.state, AgentState::Blocked);
assert_eq!(
line.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("line_regex")
);
});
}
#[test]
fn remote_manifest_loads_between_local_override_and_bundled() {
with_manifest_dirs("remote-source", || {
write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
let explain = explain(Agent::Codex, "remote-ready");
assert_eq!(explain.state, AgentState::Blocked);
assert!(matches!(
explain.source,
Some(ManifestSource::Remote { .. })
));
assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.1"));
assert_eq!(
explain.cached_remote_version.as_deref(),
Some("2026.06.10.1")
);
});
}
#[test]
fn fallback_explain_preserves_active_manifest_version() {
with_manifest_dirs("fallback-version", || {
write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
let explain = explain(Agent::Codex, "ordinary prompt text");
assert_eq!(explain.state, AgentState::Idle);
assert_eq!(
explain.fallback_reason.as_deref(),
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
);
assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.1"));
assert!(matches!(
explain.source,
Some(ManifestSource::Remote { .. })
));
});
}
#[test]
fn older_cached_remote_manifest_does_not_shadow_newer_bundled_manifest() {
with_manifest_dirs("older-remote-bundled-fallback", || {
write_remote_codex(&remote_manifest("2026.06.10.0", "blocked", "remote-ready"));
let explain = explain(Agent::Codex, "remote-ready");
assert_eq!(explain.state, AgentState::Idle);
assert!(matches!(explain.source, Some(ManifestSource::Bundled)));
assert_eq!(
explain.cached_remote_version.as_deref(),
Some("2026.06.10.0")
);
assert!(explain
.warning
.as_deref()
.is_some_and(|warning| warning.contains("older than bundled")));
});
}
#[test]
fn local_override_shadows_cached_remote_manifest() {
with_manifest_dirs("local-shadows-remote", || {
write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
write_local_codex(&local_manifest("idle", "local-ready"));
let explain = explain(Agent::Codex, "local-ready");
assert_eq!(explain.state, AgentState::Idle);
assert!(matches!(explain.source, Some(ManifestSource::Override(_))));
assert!(explain.local_override_shadowing_remote);
assert_eq!(
explain.cached_remote_version.as_deref(),
Some("2026.06.10.1")
);
});
}
#[test]
fn invalid_local_override_falls_back_to_cached_remote_manifest() {
with_manifest_dirs("invalid-local-remote-fallback", || {
write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
write_local_codex("id = ");
let explain = explain(Agent::Codex, "remote-ready");
assert_eq!(explain.state, AgentState::Blocked);
assert!(matches!(
explain.source,
Some(ManifestSource::Remote { .. })
));
assert!(explain.warning.is_some());
});
}
#[test]
fn detection_uses_cached_manifest_until_explicit_reload() {
with_manifest_dirs("cache-boundary", || {
write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "cached-ready"));
let cached = explain(Agent::Codex, "cached-ready");
assert_eq!(cached.state, AgentState::Blocked);
assert!(matches!(cached.source, Some(ManifestSource::Remote { .. })));
assert_eq!(
cached.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("test")
);
write_remote_codex_without_reload(&remote_manifest("2026.06.10.2", "working", "new-ready"));
let unchanged = explain(Agent::Codex, "new-ready");
assert_eq!(unchanged.state, AgentState::Idle);
assert_eq!(
unchanged.fallback_reason.as_deref(),
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
);
assert_eq!(
unchanged.cached_remote_version.as_deref(),
Some("2026.06.10.1")
);
reload_manifests();
let reloaded = explain(Agent::Codex, "new-ready");
assert_eq!(reloaded.state, AgentState::Working);
assert_eq!(
reloaded.cached_remote_version.as_deref(),
Some("2026.06.10.2")
);
assert_eq!(
reloaded.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("test")
);
});
}
#[test]
fn all_bundled_manifests_parse_and_validate() {
let agents = [
Agent::Pi,
Agent::Claude,
Agent::Codex,
Agent::Gemini,
Agent::Cursor,
Agent::Antigravity,
Agent::Cline,
Agent::OpenCode,
Agent::GithubCopilot,
Agent::Kimi,
Agent::Kiro,
Agent::Droid,
Agent::Amp,
Agent::Grok,
Agent::Hermes,
Agent::Kilo,
Agent::Qodercli,
];
for agent in agents {
assert!(
bundled_manifest(agent).is_some(),
"missing bundled manifest for {}",
agent_label(agent)
);
}
}
#[test]
fn manifest_validation_rejects_unknown_fields_empty_rules_invalid_regions_and_regexes() {
assert!(parse_manifest(
r#"
id = "codex"
[[rules]]
id = "typo"
state = "working"
contain = ["Working"]
"#
)
.is_err());
assert!(parse_manifest(
r#"
id = "codex"
[[rules]]
id = "empty"
state = "working"
"#
)
.is_err());
assert!(parse_manifest(
r#"
id = "codex"
[[rules]]
id = "bad_region"
state = "working"
region = "after_last_promt_marker"
contains = ["Working"]
"#
)
.is_err());
assert!(parse_manifest(
r#"
id = "codex"
[[rules]]
id = "bad_regex"
state = "working"
regex = ["["]
"#
)
.is_err());
assert!(parse_manifest(
r#"
id = "codex"
[[rules]]
id = "bad_nested_regex"
state = "working"
any = [{ line_regex = ["["] }]
"#
)
.is_err());
}
#[test]
fn manifest_validation_keeps_skip_rules_neutral() {
assert!(parse_manifest(
r#"
id = "codex"
[[rules]]
id = "bad_skip_state"
state = "idle"
skip_state_update = true
contains = ["menu"]
"#
)
.is_err());
assert!(parse_manifest(
r#"
id = "codex"
[[rules]]
id = "bad_skip_visible"
state = "unknown"
skip_state_update = true
visible_blocker = true
contains = ["menu"]
"#
)
.is_err());
}
#[test]
fn manifest_validation_rejects_excessive_rule_count() {
let mut manifest = String::from(
r#"
id = "codex"
"#,
);
for index in 0..129 {
manifest.push_str(&format!(
r#"
[[rules]]
id = "rule_{index}"
state = "idle"
contains = ["ready"]
"#
));
}
assert!(parse_manifest(&manifest).is_err());
}
#[test]
fn manifest_validation_rejects_excessive_gate_depth() {
let manifest = r#"
id = "codex"
[[rules]]
id = "deep"
state = "idle"
contains = ["ready"]
all = [
{ contains = ["1"], all = [
{ contains = ["2"], all = [
{ contains = ["3"], all = [
{ contains = ["4"], all = [
{ contains = ["5"], all = [
{ contains = ["6"], all = [
{ contains = ["7"], all = [
{ contains = ["8"], all = [
{ contains = ["9"] },
] },
] },
] },
] },
] },
] },
] },
] },
]
"#;
assert!(parse_manifest(manifest).is_err());
}
#[test]
fn manifest_validation_rejects_excessive_matchers() {
let matchers = (0..33)
.map(|index| format!(r#""m{index}""#))
.collect::<Vec<_>>()
.join(", ");
let manifest = format!(
r#"
id = "codex"
[[rules]]
id = "many"
state = "idle"
contains = [{matchers}]
"#
);
assert!(parse_manifest(&manifest).is_err());
}
#[test]
fn bottom_non_empty_lines_uses_bottom_occurrence_for_repeated_text() {
let content = "marker\nold\n\nmiddle\nmarker\nnew\n";
assert_eq!(
region(content, "bottom_non_empty_lines(2)"),
"marker\nnew\n"
);
}

View File

@ -0,0 +1,774 @@
use std::{
cmp::Ordering,
collections::{BTreeMap, BTreeSet},
fmt, fs,
io::{Read, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
time::{SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use super::{agent_label, parse_agent_label, Agent};
pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 1;
const DEFAULT_CATALOG_URL: &str = "https://herdr.dev/agent-detection/index.toml";
const CATALOG_URL_ENV: &str = "HERDR_AGENT_DETECTION_MANIFEST_CATALOG_URL";
const MAX_FETCH_BYTES: usize = 256 * 1024;
#[derive(Debug, Clone)]
pub(crate) struct ManifestVersion(String);
impl ManifestVersion {
pub(crate) fn parse(value: &str) -> Result<Self, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("version must not be empty".to_string());
}
for segment in trimmed.split('.') {
if segment.is_empty() {
return Err(format!("version {trimmed:?} contains an empty segment"));
}
if !segment.chars().all(|ch| ch.is_ascii_digit()) {
return Err(format!("version {trimmed:?} must be dotted numeric"));
}
segment
.parse::<u64>()
.map_err(|_| format!("version {trimmed:?} contains an oversized segment"))?;
}
Ok(Self(trimmed.to_string()))
}
}
impl fmt::Display for ManifestVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for ManifestVersion {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(&value).map_err(serde::de::Error::custom)
}
}
impl Serialize for ManifestVersion {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.0)
}
}
impl Ord for ManifestVersion {
fn cmp(&self, other: &Self) -> Ordering {
let mut left = self.0.split('.');
let mut right = other.0.split('.');
loop {
match (left.next(), right.next()) {
(Some(left), Some(right)) => {
let left = left.parse::<u64>().unwrap_or(0);
let right = right.parse::<u64>().unwrap_or(0);
match left.cmp(&right) {
Ordering::Equal => {}
ordering => return ordering,
}
}
(Some(left), None) => {
let left = left.parse::<u64>().unwrap_or(0);
if left == 0 {
continue;
}
return Ordering::Greater;
}
(None, Some(right)) => {
let right = right.parse::<u64>().unwrap_or(0);
if right == 0 {
continue;
}
return Ordering::Less;
}
(None, None) => return Ordering::Equal,
}
}
}
}
impl PartialOrd for ManifestVersion {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for ManifestVersion {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for ManifestVersion {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ManifestUpdateCommit {
pub(crate) agent: Agent,
pub(crate) version: ManifestVersion,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub(crate) struct ManifestUpdateStatus {
pub(crate) last_check_unix: Option<u64>,
pub(crate) last_result: Option<String>,
#[serde(default)]
pub(crate) agents: BTreeMap<String, AgentRemoteStatus>,
}
impl ManifestUpdateStatus {
pub(crate) fn agent_status(&self, agent: Agent) -> Option<AgentRemoteStatus> {
self.agents.get(agent_label(agent)).cloned()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct AgentRemoteStatus {
pub(crate) cached_version: Option<String>,
pub(crate) attempted_version: Option<String>,
pub(crate) last_checked_unix: Option<u64>,
pub(crate) last_result: String,
pub(crate) last_error: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ManifestCatalog {
schema_version: u32,
#[serde(default)]
agents: Vec<ManifestCatalogAgent>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ManifestCatalogAgent {
id: String,
path: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CatalogAgent {
agent: Agent,
path: String,
}
pub(crate) fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
let result = check_and_update();
let status = match result {
Ok(output) => {
if !output.updated.is_empty() {
super::manifest::reload_manifests();
}
let _ = events.blocking_send(crate::events::AppEvent::AgentDetectionManifestsUpdated {
updated: output.updated,
status: output.status,
});
return;
}
Err(err) => {
tracing::warn!("agent detection manifest update failed: {err}");
let mut status = load_status();
status.last_check_unix = Some(now_unix());
status.last_result = Some(format!("failed: {err}"));
let _ = save_status(&status);
status
}
};
let _ = events.blocking_send(crate::events::AppEvent::AgentDetectionManifestsUpdated {
updated: Vec::new(),
status,
});
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ManifestUpdateOutput {
pub(crate) updated: Vec<ManifestUpdateCommit>,
pub(crate) status: ManifestUpdateStatus,
}
pub(crate) fn check_and_update() -> Result<ManifestUpdateOutput, String> {
check_and_update_from_url(&catalog_url())
}
fn check_and_update_from_url(url: &str) -> Result<ManifestUpdateOutput, String> {
let catalog = parse_catalog(&fetch_text(url)?)?;
let base_url = base_url(url)?;
let mut status = load_status();
let check_time = now_unix();
status.last_check_unix = Some(check_time);
status.last_result = Some("checked".to_string());
let mut updated = Vec::new();
for entry in catalog {
let agent_id = agent_label(entry.agent).to_string();
let manifest_url = join_url(&base_url, &entry.path)?;
match fetch_text(&manifest_url)
.map_err(|err| format!("fetch failed: {err}"))
.and_then(|content| process_agent_manifest(entry.agent, &content, check_time))
{
Ok(Some(commit)) => {
status.agents.insert(
agent_id,
AgentRemoteStatus {
cached_version: Some(commit.version.to_string()),
attempted_version: Some(commit.version.to_string()),
last_checked_unix: Some(check_time),
last_result: "updated".to_string(),
last_error: None,
},
);
updated.push(commit);
}
Ok(None) => {
let cached_version = cached_remote_version(entry.agent);
status.agents.insert(
agent_id,
AgentRemoteStatus {
cached_version: cached_version.map(|version| version.to_string()),
attempted_version: None,
last_checked_unix: Some(check_time),
last_result: "current".to_string(),
last_error: None,
},
);
}
Err(err) => {
tracing::warn!(
agent = agent_label(entry.agent),
error = %err,
"agent detection manifest update failed for agent"
);
let cached_version = cached_remote_version(entry.agent);
status.agents.insert(
agent_id,
AgentRemoteStatus {
cached_version: cached_version.map(|version| version.to_string()),
attempted_version: None,
last_checked_unix: Some(check_time),
last_result: "failed".to_string(),
last_error: Some(err),
},
);
}
}
}
if let Err(err) = save_status(&status) {
tracing::warn!("failed to save agent detection manifest update status: {err}");
status.last_result = Some(format!("failed_to_save_status: {err}"));
}
Ok(ManifestUpdateOutput { updated, status })
}
fn process_agent_manifest(
agent: Agent,
content: &str,
_check_time: u64,
) -> Result<Option<ManifestUpdateCommit>, String> {
let parsed = super::manifest::parse_remote_manifest_for_agent(agent, content)?;
if let Some(current) = cached_remote_version(agent) {
match parsed.version.cmp(&current) {
Ordering::Less => {
return Err(format!(
"remote version {} is older than cached {current}",
parsed.version
))
}
Ordering::Equal => {
let committed = fs::read_to_string(remote_manifest_path(agent)).unwrap_or_default();
if committed != content {
return Err(format!(
"remote version {} changed content without a version bump",
parsed.version
));
}
return Ok(None);
}
Ordering::Greater => {}
}
}
commit_remote_manifest(agent, content)?;
Ok(Some(ManifestUpdateCommit {
agent,
version: parsed.version,
}))
}
fn parse_catalog(content: &str) -> Result<Vec<CatalogAgent>, String> {
let catalog: ManifestCatalog =
toml::from_str(content).map_err(|err| format!("failed to parse catalog TOML: {err}"))?;
if catalog.schema_version != 1 {
return Err(format!(
"unsupported catalog schema_version {}",
catalog.schema_version
));
}
let mut seen = BTreeSet::new();
let mut agents = Vec::new();
for entry in catalog.agents {
let Some(agent) = parse_agent_label(&entry.id) else {
tracing::warn!(agent = entry.id, "skipping unknown remote manifest agent");
continue;
};
if entry.path.trim().is_empty() {
return Err(format!("catalog entry {} has an empty path", entry.id));
}
if entry.path.contains("://")
|| entry.path.starts_with('/')
|| entry.path.split('/').any(|part| part == "..")
{
return Err(format!(
"catalog entry {} has an unsafe path {}",
entry.id, entry.path
));
}
if !seen.insert(agent_label(agent).to_string()) {
return Err(format!("catalog contains duplicate agent {}", entry.id));
}
agents.push(CatalogAgent {
agent,
path: entry.path,
});
}
Ok(agents)
}
pub(crate) fn load_status() -> ManifestUpdateStatus {
let path = status_path();
let Ok(content) = fs::read_to_string(&path) else {
return ManifestUpdateStatus::default();
};
toml::from_str(&content).unwrap_or_else(|err| {
tracing::warn!(
path = %path.display(),
"failed to parse agent detection manifest status: {err}"
);
ManifestUpdateStatus::default()
})
}
fn save_status(status: &ManifestUpdateStatus) -> Result<(), String> {
let path = status_path();
let parent = path
.parent()
.ok_or_else(|| format!("status path {} has no parent", path.display()))?;
fs::create_dir_all(parent).map_err(|err| err.to_string())?;
let content = toml::to_string_pretty(status).map_err(|err| err.to_string())?;
atomic_write(&path, content.as_bytes())
}
pub(crate) fn status_path() -> PathBuf {
state_root().join("status.toml")
}
pub(crate) fn remote_manifest_path(agent: Agent) -> PathBuf {
state_root()
.join("remote")
.join(format!("{}.toml", agent_label(agent)))
}
pub(crate) fn cached_remote_version(agent: Agent) -> Option<ManifestVersion> {
let content = fs::read_to_string(remote_manifest_path(agent)).ok()?;
super::manifest::parse_remote_manifest_for_agent(agent, &content)
.ok()
.map(|parsed| parsed.version)
}
fn commit_remote_manifest(agent: Agent, content: &str) -> Result<(), String> {
let path = remote_manifest_path(agent);
let parent = path
.parent()
.ok_or_else(|| format!("remote manifest path {} has no parent", path.display()))?;
fs::create_dir_all(parent).map_err(|err| err.to_string())?;
atomic_write(&path, content.as_bytes())
}
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
let parent = path
.parent()
.ok_or_else(|| format!("path {} has no parent", path.display()))?;
fs::create_dir_all(parent).map_err(|err| err.to_string())?;
let tmp_path = parent.join(format!(
".{}.{}.{}.tmp",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("manifest"),
std::process::id(),
now_nanos()
));
{
let mut file = fs::File::create(&tmp_path).map_err(|err| err.to_string())?;
if let Err(err) = file.write_all(bytes).and_then(|_| file.sync_all()) {
let _ = fs::remove_file(&tmp_path);
return Err(err.to_string());
}
}
fs::rename(&tmp_path, path).map_err(|err| {
let _ = fs::remove_file(&tmp_path);
err.to_string()
})?;
if let Err(err) = sync_parent_dir(parent) {
tracing::warn!(
path = %path.display(),
error = %err,
"agent detection manifest committed but parent directory sync failed"
);
}
Ok(())
}
fn sync_parent_dir(parent: &Path) -> Result<(), String> {
let dir = match fs::File::open(parent) {
Ok(dir) => dir,
Err(err) if directory_sync_unsupported(&err) => return Ok(()),
Err(err) => return Err(format!("failed to open parent directory for sync: {err}")),
};
match dir.sync_all() {
Ok(()) => Ok(()),
Err(err) if directory_sync_unsupported(&err) => Ok(()),
Err(err) => Err(format!("failed to sync parent directory: {err}")),
}
}
fn directory_sync_unsupported(err: &std::io::Error) -> bool {
matches!(
err.kind(),
std::io::ErrorKind::Unsupported | std::io::ErrorKind::InvalidInput
)
}
fn state_root() -> PathBuf {
crate::config::state_dir().join("agent-detection")
}
fn catalog_url() -> String {
std::env::var(CATALOG_URL_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_CATALOG_URL.to_string())
}
fn fetch_text(url: &str) -> Result<String, String> {
let max_fetch_bytes = MAX_FETCH_BYTES.to_string();
let mut child = Command::new("curl")
.args([
"-sfL",
"--retry",
"2",
"--connect-timeout",
"5",
"--max-time",
"15",
"--max-filesize",
&max_fetch_bytes,
url,
])
.stdout(Stdio::piped())
.spawn()
.map_err(|err| format!("curl failed: {err}"))?;
let mut bytes = Vec::new();
let Some(stdout) = child.stdout.as_mut() else {
let _ = child.kill();
let _ = child.wait();
return Err("curl stdout was not captured".to_string());
};
stdout
.take((MAX_FETCH_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|err| {
let _ = child.kill();
let _ = child.wait();
format!("failed to read curl response: {err}")
})?;
if bytes.len() > MAX_FETCH_BYTES {
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"response from {url} exceeded {MAX_FETCH_BYTES} bytes"
));
}
let status = child
.wait()
.map_err(|err| format!("curl wait failed: {err}"))?;
if !status.success() {
return Err(format!("failed to fetch {url}"));
}
String::from_utf8(bytes).map_err(|err| format!("response was not UTF-8: {err}"))
}
fn base_url(url: &str) -> Result<String, String> {
let Some((base, _)) = url.rsplit_once('/') else {
return Err(format!("catalog URL {url} has no base path"));
};
Ok(base.to_string())
}
fn join_url(base: &str, path: &str) -> Result<String, String> {
if path.contains("://") || path.starts_with('/') || path.split('/').any(|part| part == "..") {
return Err(format!("unsafe manifest path {path}"));
}
Ok(format!("{}/{}", base.trim_end_matches('/'), path))
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
fn now_nanos() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn remote_manifest(version: &str, contains: &str) -> String {
format!(
r#"
id = "codex"
version = "{version}"
min_engine_version = 1
updated_at = "2026-06-10T12:00:00Z"
[[rules]]
id = "idle"
state = "idle"
contains = ["{contains}"]
"#
)
}
fn with_state_dir<T>(name: &str, f: impl FnOnce() -> T) -> T {
let _guard = crate::config::test_config_env_lock().lock().unwrap();
let old_config = std::env::var_os("XDG_CONFIG_HOME");
let old_state = std::env::var_os("XDG_STATE_HOME");
let dir = std::env::temp_dir().join(format!(
"herdr-manifest-update-{name}-{}",
std::process::id()
));
let config_dir = dir.join("config");
let state_dir = dir.join("state");
let _ = fs::remove_dir_all(&dir);
std::env::set_var("XDG_CONFIG_HOME", &config_dir);
std::env::set_var("XDG_STATE_HOME", &state_dir);
crate::detect::manifest::reload_manifests();
let result = f();
match old_config {
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
match old_state {
Some(value) => std::env::set_var("XDG_STATE_HOME", value),
None => std::env::remove_var("XDG_STATE_HOME"),
}
crate::detect::manifest::reload_manifests();
let _ = fs::remove_dir_all(&dir);
result
}
#[test]
fn manifest_version_compares_dotted_numeric_segments() {
assert!(
ManifestVersion::parse("2026.6.10.1").unwrap()
> ManifestVersion::parse("2026.6.9.9").unwrap()
);
assert!(ManifestVersion::parse("1.2.0").unwrap() == ManifestVersion::parse("1.2").unwrap());
assert!(ManifestVersion::parse("1.2.1").unwrap() > ManifestVersion::parse("1.2").unwrap());
}
#[test]
fn manifest_version_rejects_non_numeric_segments() {
assert!(ManifestVersion::parse("").is_err());
assert!(ManifestVersion::parse("2026.06.alpha").is_err());
assert!(ManifestVersion::parse("2026..06").is_err());
assert!(ManifestVersion::parse("2026.999999999999999999999999999999").is_err());
}
#[test]
fn process_agent_manifest_commits_newer_manifest_atomically() {
with_state_dir("commit-newer", || {
let content = remote_manifest("2026.06.10.1", "ready");
let commit = process_agent_manifest(Agent::Codex, &content, 1)
.unwrap()
.unwrap();
assert_eq!(commit.agent, Agent::Codex);
assert_eq!(
commit.version,
ManifestVersion::parse("2026.06.10.1").unwrap()
);
assert_eq!(
fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(),
content
);
});
}
#[test]
fn auto_update_reloads_manifest_cache_after_remote_commit() {
with_state_dir("auto-update-reloads-cache", || {
let old_catalog_url = std::env::var_os(CATALOG_URL_ENV);
let web_dir = std::env::temp_dir()
.join(format!("herdr-manifest-update-web-{}", std::process::id()));
let _ = fs::remove_dir_all(&web_dir);
fs::create_dir_all(&web_dir).unwrap();
fs::write(
web_dir.join("index.toml"),
r#"
schema_version = 1
[[agents]]
id = "codex"
path = "codex.toml"
"#,
)
.unwrap();
fs::write(
web_dir.join("codex.toml"),
remote_manifest("2026.06.10.1", "auto-update-ready"),
)
.unwrap();
std::env::set_var(
CATALOG_URL_ENV,
format!("file://{}", web_dir.join("index.toml").display()),
);
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
auto_update(tx);
let event = rx.try_recv().expect("manifest update event");
let crate::events::AppEvent::AgentDetectionManifestsUpdated { updated, .. } = event
else {
panic!("unexpected event");
};
assert_eq!(updated.len(), 1);
assert_eq!(updated[0].agent, Agent::Codex);
let explain = crate::detect::manifest::explain(Agent::Codex, "auto-update-ready");
assert_eq!(explain.state, crate::detect::AgentState::Idle);
assert!(matches!(
explain.source,
Some(crate::detect::manifest::ManifestSource::Remote { .. })
));
assert_eq!(
explain.matched_rule.as_ref().map(|rule| rule.id.as_str()),
Some("idle")
);
match old_catalog_url {
Some(value) => std::env::set_var(CATALOG_URL_ENV, value),
None => std::env::remove_var(CATALOG_URL_ENV),
}
let _ = fs::remove_dir_all(&web_dir);
});
}
#[test]
fn process_agent_manifest_rejects_downgrade_and_keeps_cached_manifest() {
with_state_dir("reject-downgrade", || {
let current = remote_manifest("2026.06.10.2", "current");
process_agent_manifest(Agent::Codex, &current, 1).unwrap();
let older = remote_manifest("2026.06.10.1", "older");
assert!(process_agent_manifest(Agent::Codex, &older, 2).is_err());
assert_eq!(
fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(),
current
);
});
}
#[test]
fn process_agent_manifest_rejects_equal_version_content_change() {
with_state_dir("reject-equal-change", || {
let current = remote_manifest("2026.06.10.1", "current");
process_agent_manifest(Agent::Codex, &current, 1).unwrap();
let changed = remote_manifest("2026.06.10.1", "changed");
assert!(process_agent_manifest(Agent::Codex, &changed, 2).is_err());
assert_eq!(
fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(),
current
);
});
}
#[test]
fn process_agent_manifest_skips_same_version_same_content() {
with_state_dir("skip-same", || {
let current = remote_manifest("2026.06.10.1", "current");
process_agent_manifest(Agent::Codex, &current, 1).unwrap();
let result = process_agent_manifest(Agent::Codex, &current, 2).unwrap();
assert!(result.is_none());
});
}
#[test]
fn catalog_parses_known_agents_and_rejects_duplicates() {
let catalog = parse_catalog(
r#"
schema_version = 1
[[agents]]
id = "codex"
path = "codex.toml"
"#,
)
.unwrap();
assert_eq!(catalog[0].agent, Agent::Codex);
assert_eq!(catalog[0].path, "codex.toml");
assert!(parse_catalog(
r#"
schema_version = 1
[[agents]]
id = "codex"
path = "codex.toml"
[[agents]]
id = "codex"
path = "codex-2.toml"
"#
)
.is_err());
}
#[test]
fn catalog_rejects_unsafe_paths() {
assert!(parse_catalog(
r#"
schema_version = 1
[[agents]]
id = "codex"
path = "../codex.toml"
"#
)
.is_err());
}
}

View File

@ -0,0 +1,29 @@
id = "amp"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
aliases = ["amp-local"]
[[rules]]
id = "approval_footer"
state = "blocked"
priority = 300
region = "whole_recent"
visible_blocker = true
any = [
{ contains = ["waiting for approval"] },
{ contains = ["invoke tool"] },
{ contains = ["run this command?"] },
{ contains = ["allow editing file:"] },
{ contains = ["allow creating file:"] },
{ contains = ["confirm tool call"] },
{ contains = ["approve"], any = [{ contains = ["allow all for this session"] }, { contains = ["allow all for every session"] }, { contains = ["allow file for every session"] }, { contains = ["deny with feedback"] }] },
]
[[rules]]
id = "esc_cancel_working"
state = "working"
priority = 100
region = "whole_recent"
visible_working = true
contains = ["esc to cancel"]

View File

@ -0,0 +1,33 @@
id = "agy"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
aliases = ["antigravity", "antigravity-cli"]
[[rules]]
id = "permission_prompt"
state = "blocked"
priority = 300
region = "whole_recent"
visible_blocker = true
contains = ["requesting permission for:"]
any = [
{ contains = ["do you want to proceed?"] },
{ contains = ["tab amend", "edit command"] },
]
[[rules]]
id = "spinner_working"
state = "working"
priority = 100
region = "whole_recent"
visible_working = true
line_regex = ['^\s*[\u2800-\u28FF]+\s+\p{Alphabetic}+\w*ing\b']
[[rules]]
id = "background_tasks_working"
state = "working"
priority = 90
region = "bottom_non_empty_lines(5)"
visible_working = true
line_regex = ['(?i).*[1-9][0-9]*\s*·?\s+tasks?.*/tasks']

View File

@ -0,0 +1,179 @@
id = "claude"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
aliases = ["claude-code"]
[[rules]]
id = "transcript_viewer"
state = "unknown"
priority = 1000
region = "bottom_non_empty_lines(3)"
skip_state_update = true
contains = ["showing detailed transcript"]
any = [
{ contains = ["ctrl+o", "to toggle"] },
{ contains = ["ctrl+e", "show all"] },
{ contains = ["ctrl+e", "collapse"] },
{ contains = ["↑↓ scroll"] },
{ contains = ["? for shortcuts"] },
]
[[rules]]
id = "live_prompt_box"
state = "idle"
priority = 950
region = "prompt_box_body"
visible_idle = true
line_regex = ['^\s*']
not = [
{ contains = ["enter to select"] },
{ contains = ["esc to cancel"] },
{ contains = ["tab/arrow keys"] },
{ contains = ["arrow keys to navigate"] },
{ contains = ["↑/↓ to navigate"] },
]
[[rules]]
id = "model_picker_menu"
state = "unknown"
priority = 900
region = "whole_recent"
skip_state_update = true
contains = ["select model", "enter to set as default", "esc to cancel"]
not = [
{ contains = ["do you want to proceed?"] },
{ contains = ["enter to select"] },
]
[[rules]]
id = "live_blocked_form"
state = "blocked"
priority = 980
region = "after_last_horizontal_rule"
visible_blocker = true
contains = ["enter to select", "esc to cancel"]
any = [
{ contains = ["tab/arrow keys to navigate"] },
{ contains = ["arrow keys to navigate"] },
{ contains = ["arrows to navigate"] },
{ contains = ["↑/↓ to navigate"] },
{ contains = ["↑↓ to navigate"] },
]
[[rules]]
id = "dynamic_workflow_prompt"
state = "blocked"
priority = 980
region = "whole_recent"
visible_blocker = true
contains = ["run a dynamic workflow?", "esc to cancel"]
[[rules]]
id = "generic_permission_prompt"
state = "blocked"
priority = 840
region = "after_last_horizontal_rule"
visible_blocker = true
contains = ["do you want to proceed?", "esc to cancel"]
all = [
{ any = [
{ line_regex = ['(?i)^\s*?\s*1\.\s*yes\b'] },
{ line_regex = ['(?i)^\s*2\.\s*yes\b'] },
{ line_regex = ['(?i)^\s*2\.\s*no\b'] },
{ line_regex = ['(?i)^\s*3\.\s*no\b'] },
] },
]
[[rules]]
id = "bash_permission_prompt"
state = "blocked"
priority = 850
region = "whole_recent"
visible_blocker = true
contains = ["do you want to proceed?"]
any = [
{ contains = ["bash command"] },
{ contains = ["bash("] },
{ contains = ["contains expansion"] },
{ contains = ["tab to amend"] },
{ contains = ["ctrl+e to explain"] },
]
all = [
{ any = [{ line_regex = ['(?i)^\s*?\s*yes\b'] }, { line_regex = ['(?i)^\s*1\.\s*yes\b'] }, { line_regex = ['(?i)^\s*2\.\s*no\b'] }] },
]
[[rules]]
id = "interrupt_chrome_working"
state = "working"
priority = 970
region = "above_prompt_box"
visible_working = true
any = [
{ contains = ["esc to interrupt"] },
{ contains = ["ctrl+c to interrupt"] },
]
[[rules]]
id = "spinner_activity_working"
state = "working"
priority = 960
region = "above_prompt_box"
visible_working = true
line_regex = ['^\s*[·✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆]\s+.*\p{Alphabetic}.*…']
[[rules]]
id = "ascii_spinner_activity_working"
state = "working"
priority = 960
region = "above_prompt_box"
visible_working = true
line_regex = ['^\s*\*\s+\p{Alphabetic}.*…\s*\([^)]*\b[1-9][0-9]*s\b[^)]*\)']
[[rules]]
id = "latest_background_status_working"
state = "working"
priority = 960
region = "last_non_empty_above_prompt_box"
visible_working = true
any = [
{ regex = ['(?i)^\W*waiting for [1-9][0-9]* background agents? to finish$'] },
{ regex = ['(?i).*\b[1-9][0-9]* shells? still running\b'] },
{ regex = ['(?i).*\b[1-9][0-9]* local agents? still running\b'] },
]
[[rules]]
id = "bottom_agent_status_working"
state = "working"
priority = 955
region = "bottom_non_empty_lines(10)"
visible_working = true
line_regex = ['^\s*◯\s+(?:Explore|Agent)\b.*\b[1-9][0-9]*s\b(?:\s*·.*)?$']
[[rules]]
id = "dynamic_workflow_progress_working"
state = "working"
priority = 965
region = "bottom_non_empty_lines(10)"
visible_working = true
line_regex = ['^\s*[◯●]\s+.*\b(?:[0-9]+/[1-9][0-9]*) agents done\b.*\b[1-9][0-9]*s\b']
[[rules]]
id = "legacy_no_prompt_blocker"
state = "blocked"
priority = 300
region = "whole_recent"
any = [
{ contains = ["do you want to"], any = [{ contains = ["yes"] }, { contains = [""] }] },
{ contains = ["would you like to"], any = [{ contains = ["yes"] }, { contains = [""] }] },
{ contains = ["waiting for permission"] },
{ contains = ["do you want to allow this connection?"] },
{ contains = ["tab to amend"] },
{ contains = ["ctrl+e to explain"] },
{ contains = ["do you want to proceed?", "esc to cancel"] },
{ contains = ["review your answers"] },
{ contains = ["skip interview and plan immediately"] },
]
not = [
{ regex = ['(?m)^\s*\s*$'] },
]

View File

@ -0,0 +1,26 @@
id = "cline"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
[[rules]]
id = "tool_permission"
state = "blocked"
priority = 300
region = "whole_recent"
visible_blocker = true
any = [
{ contains = ["let cline use this tool"] },
{ contains = ["[act mode]", "execute command?", "yes"] },
{ contains = ["[act mode]", "use this tool?", "yes"] },
{ contains = ["[plan mode]", "execute command?", "yes"] },
{ contains = ["[plan mode]", "use this tool?", "yes"] },
]
[[rules]]
id = "default_cline_working"
state = "working"
priority = -10
region = "whole_recent"
visible_working = true
regex = ['(?s).+']

View File

@ -0,0 +1,105 @@
id = "codex"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
[[rules]]
id = "transcript_viewer"
state = "unknown"
priority = 1000
region = "after_last_prompt_marker"
skip_state_update = true
contains = ["↑/↓ to scroll", "pgup/pgdn to", "home/end to jump", "q to quit"]
any = [
{ contains = ["esc to edit prev"] },
{ contains = ["esc/← to edit prev"] },
]
[[rules]]
id = "live_strong_blocker"
state = "blocked"
priority = 900
region = "after_last_prompt_marker"
visible_blocker = true
any = [
{ contains = ["press enter to confirm or esc to cancel"] },
{ contains = ["enter to submit answer"] },
{ contains = ["enter to submit all"] },
{ contains = ["allow command?"] },
]
[[rules]]
id = "current_live_working"
state = "working"
priority = 710
region = "current_prompt_block_marker"
visible_working = true
any = [
{ line_regex = ['(?i)^\s*• queued follow-up inputs'] },
{ line_regex = ['(?i)^\s*• messages to be submitted after next tool call'] },
{ contains = ["Waiting for background terminal"] },
{ contains = ["background terminal running"] },
{ contains = ["/ps to view"] },
{ contains = ["/stop to close"] },
{ regex = ['(?i)^\s*•.*\([0-9]+[hms](?: [0-9]+[hms]){0,2}.*esc\s*(…|to interrupt|to .+…)'] },
]
[[rules]]
id = "current_working_status"
state = "working"
priority = 700
region = "current_prompt_block_marker"
any = [
{ line_regex = ['(?i)^\s*• queued follow-up inputs'] },
{ line_regex = ['(?i)^\s*• messages to be submitted after next tool call'] },
{ contains = ["Working ("] },
{ contains = ["Waiting for background terminal ("] },
{ contains = ["Booting MCP server:"] },
{ contains = ["reviewing approval request ("] },
{ regex = ['(?i)reviewing .* approval requests \('] },
{ regex = ['(?i)^\s*•.*\([0-9]+[hms](?: [0-9]+[hms]){0,2}\).* • esc(…| to interrupt| to .+…)'] },
]
[[rules]]
id = "current_working_status_with_detail"
state = "working"
priority = 690
region = "after_current_prompt_block_marker"
any = [
{ contains = ["Queued follow-up inputs"] },
{ contains = ["Messages to be submitted after next tool call"] },
]
[[rules]]
id = "weak_blocker"
state = "blocked"
priority = 600
region = "whole_recent"
any = [
{ contains = ["[y/n]"] },
{ contains = ["yes (y)"] },
{ contains = ["do you want to"], any = [{ contains = ["yes"] }, { contains = [""] }] },
{ contains = ["would you like to"], any = [{ contains = ["yes"] }, { contains = [""] }] },
]
[[rules]]
id = "fallback_working"
state = "working"
priority = 500
region = "whole_recent_without_current_prompt_marker"
visible_working = true
any = [
{ line_regex = ['(?i)^\s*•.*\([0-9]+[hms](?: [0-9]+[hms]){0,2}.*esc\s*(…|to interrupt|to .+…)'] },
{ contains = ["esc to interrupt"] },
{ all = [{ contains = ["ctrl+c to interrupt"] }, { line_regex = ['(?i)^\s*•'] }] },
{ all = [{ contains = ["press esc to interrupt"] }, { line_regex = ['(?i)^\s*•'] }] },
]
[[rules]]
id = "fallback_status_working"
state = "working"
priority = 490
region = "whole_recent_without_current_prompt_marker"
any = [
{ line_regex = ['(?i)^\s*•.*(Working \(|Waiting for background terminal \(|Booting MCP server:|reviewing approval request \(|reviewing .* approval requests \()'] },
]

View File

@ -0,0 +1,57 @@
id = "cursor"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
aliases = ["cursor-agent"]
[[rules]]
id = "write_file_approval"
state = "blocked"
priority = 320
region = "bottom_non_empty_lines(8)"
visible_blocker = true
contains = ["write to this file?", "proceed (y)"]
any = [
{ contains = ["reject & propose changes"] },
{ contains = ["esc or n or p"] },
{ contains = ["add write("] },
]
[[rules]]
id = "approval_prompt"
state = "blocked"
priority = 300
region = "whole_recent"
visible_blocker = true
any = [
{ contains = ["waiting for approval", "run this command?"], any = [{ contains = ["run (once) (y)"] }, { contains = ["skip (esc or n)"] }] },
{ contains = ["(y) (enter)"] },
{ line_regex = ['(?i)^\s*allow .*\(y\)'] },
{ contains = ["keep (n)"] },
{ contains = ["skip (esc or n)"] },
{ line_regex = ['(?i)^\s*(run |.*\(y\).*(allow|run \(once\)|→ run))'] },
]
[[rules]]
id = "stop_hint_working"
state = "working"
priority = 100
region = "bottom_non_empty_lines(6)"
visible_working = true
contains = ["ctrl+c to stop"]
[[rules]]
id = "background_task_status_working"
state = "working"
priority = 95
region = "bottom_non_empty_lines(5)"
visible_working = true
line_regex = ['(?i)\b[1-9][0-9]*\s+background\s+tasks?\b']
[[rules]]
id = "spinner_working"
state = "working"
priority = 90
region = "bottom_non_empty_lines(8)"
visible_working = true
line_regex = ['^\s*(⬡|⬢|[\u2800-\u28FF]+)\s+\p{Alphabetic}+\w*ing\b']

View File

@ -0,0 +1,48 @@
id = "droid"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
[[rules]]
id = "execute_selection_blocker"
state = "blocked"
priority = 300
region = "whole_recent"
visible_blocker = true
contains = ["enter to select", "esc to cancel"]
any = [
{ contains = ["↑↓ to navigate"] },
{ contains = ["use ↑↓ to navigate"] },
]
all = [
{ any = [{ contains = ["> yes, allow"] }, { contains = ["> no, cancel"] }] },
]
[[rules]]
id = "selection_menu_blocker"
state = "blocked"
priority = 290
region = "bottom_non_empty_lines(8)"
visible_blocker = true
contains = ["enter select", "esc cancel"]
any = [
{ contains = ["↑/↓ navigate"] },
{ contains = ["↑↓ navigate"] },
]
[[rules]]
id = "spinner_stop_working"
state = "working"
priority = 110
region = "whole_recent"
visible_working = true
contains = ["esc to stop"]
line_regex = ['^\s*[\u2800-\u28FF]']
[[rules]]
id = "stop_hint_working"
state = "working"
priority = 100
region = "whole_recent"
visible_working = true
contains = ["esc to stop"]

View File

@ -0,0 +1,25 @@
id = "gemini"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
[[rules]]
id = "apply_or_allow_change"
state = "blocked"
priority = 300
region = "whole_recent"
visible_blocker = true
any = [
{ contains = ["│ Apply this change"] },
{ contains = ["│ Allow execution"] },
{ all = [{ contains = ["yes"] }, { any = [{ contains = ["waiting for user confirmation"] }, { contains = ["│ Do you want to proceed"] }, { contains = ["do you want to proceed?"] }] }] },
{ line_regex = ['(?i)^\s*.*(yes|allow)'] },
]
[[rules]]
id = "esc_cancel_working"
state = "working"
priority = 100
region = "whole_recent"
visible_working = true
contains = ["esc to cancel"]

View File

@ -0,0 +1,30 @@
id = "copilot"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
aliases = ["github-copilot", "ghcs"]
[[rules]]
id = "selection_blocker"
state = "blocked"
priority = 300
region = "whole_recent"
visible_blocker = true
contains = ["esc to cancel"]
any = [
{ contains = ["enter to select"] },
{ contains = ["enter to confirm"] },
{ contains = ["enter to submit"] },
]
[[rules]]
id = "working_cancel_hint"
state = "working"
priority = 100
region = "whole_recent"
visible_working = true
any = [
{ contains = ["esc to cancel"] },
{ contains = ["esc cancel"] },
{ contains = ["esc again to cancel"] },
]

View File

@ -0,0 +1,28 @@
id = "grok"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
aliases = ["grok-build"]
[[rules]]
id = "permission_scope_selector"
state = "blocked"
priority = 300
region = "whole_recent"
visible_blocker = true
contains = ["yes, proceed", "no, reject"]
any = [
{ contains = ["use ← → to choose permission whitelist scope"] },
{ contains = ["←/→:scope"] },
]
[[rules]]
id = "waiting_tool_working"
state = "working"
priority = 120
region = "whole_recent"
visible_working = true
any = [
{ all = [{ contains = ["ctrl+c:cancel", "ctrl+enter:interject"] }, { contains = ["waiting"] }] },
{ line_regex = ['^\s*[\u2800-\u28FF]\s+(Run|Read|Search|List)\b'] },
]

View File

@ -0,0 +1,30 @@
id = "hermes"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
aliases = ["hermes-agent"]
[[rules]]
id = "dangerous_command_approval"
state = "blocked"
priority = 300
region = "whole_recent"
visible_blocker = true
any = [
{ contains = ["dangerous command"] },
{ contains = ["allow once", "allow for this session", "deny"] },
]
all = [
{ any = [{ contains = ["enter to confirm"] }, { contains = ["↑/↓ to select"] }, { contains = ["show full command"] }] },
]
[[rules]]
id = "interrupt_status_working"
state = "working"
priority = 100
region = "whole_recent"
visible_working = true
any = [
{ contains = ["msg=interrupt"] },
{ contains = ["ctrl+c cancel"] },
]

View File

@ -0,0 +1,24 @@
id = "kilo"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
aliases = ["kilo-code", "kilo code", "herdr:kilo"]
[[rules]]
id = "opencode_permission"
state = "blocked"
priority = 300
region = "whole_recent"
visible_blocker = true
any = [
{ contains = ["△ Permission required"] },
{ contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] },
]
[[rules]]
id = "esc_interrupt_working"
state = "working"
priority = 100
region = "whole_recent"
visible_working = true
contains = ["esc interrupt"]

View File

@ -0,0 +1,77 @@
id = "kimi"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
aliases = ["kimi-code", "kimi code"]
[[rules]]
id = "current_approval_panel"
state = "blocked"
priority = 400
region = "whole_recent"
visible_blocker = true
contains = ["↵ confirm"]
any = [
{ contains = ["run this command?"] },
{ contains = ["write this file?"] },
{ contains = ["apply these edits?"] },
{ contains = ["stop this task?"] },
{ contains = ["ready to build with this plan?"] },
{ line_regex = ['(?i)^\s*▶?\s*approve .*\?$'] },
]
all = [
{ contains = [" choose"] },
{ any = [{ contains = ["approve"] }, { contains = ["reject"] }, { contains = ["revise"] }] },
]
[[rules]]
id = "question_panel"
state = "blocked"
priority = 390
region = "whole_recent"
visible_blocker = true
contains = ["↑↓ select", "esc cancel"]
line_regex = ['^\s*question\s*$', '^\s*\? ']
any = [
{ contains = ["↵ choose"] },
{ contains = ["↵ toggle"] },
{ contains = ["↵ save"] },
]
[[rules]]
id = "legacy_approval_panel"
state = "blocked"
priority = 300
region = "whole_recent"
contains = ["requesting approval", "reject"]
any = [
{ contains = ["approve once"] },
{ contains = ["approve for this session"] },
]
all = [
{ any = [{ contains = ["1/2/3/4 choose"] }, { contains = ["↵ confirm"] }] },
]
[[rules]]
id = "background_agent_status_working"
state = "working"
priority = 120
region = "bottom_non_empty_lines(3)"
visible_working = true
line_regex = ['(?i)\bkimi[-\w.]*\s+thinking\b.*\[[1-9][0-9]*\s+agents?\s+running\]']
[[rules]]
id = "moon_spinner_working"
state = "working"
priority = 100
region = "whole_recent"
visible_working = true
line_regex = ['^\s*(🌕|🌖|🌗|🌘|🌑|🌒|🌓|🌔)\s*$']
[[rules]]
id = "braille_spinner_working"
state = "working"
priority = 90
region = "whole_recent"
visible_working = true
line_regex = ['(?i)^\s*[\u2800-\u28FF]+\s*(thinking\.\.\.|working\.\.\.|using )']

View File

@ -0,0 +1,51 @@
id = "kiro"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
aliases = ["kiro-cli"]
[[rules]]
id = "tool_approval"
state = "blocked"
priority = 300
region = "whole_recent"
visible_blocker = true
contains = ["requires approval"]
any = [
{ contains = ["yes, single permission"] },
{ contains = ["trust, always allow"] },
{ contains = ["no (tab to edit)"] },
{ contains = ["esc to close"] },
]
[[rules]]
id = "subagent_approval"
state = "blocked"
priority = 290
region = "whole_recent"
visible_blocker = true
contains = ["pending from subagents"]
any = [
{ contains = ["tool approval"] },
{ contains = ["tool approvals"] },
]
all = [
{ any = [{ contains = ["approve all pending"] }, { contains = ["configure individually"] }, { contains = ["exit (cancel subagents)"] }] },
]
[[rules]]
id = "kiro_working_marker"
state = "working"
priority = 100
region = "whole_recent"
visible_working = true
contains = ["kiro is working"]
[[rules]]
id = "tool_spinner_working"
state = "working"
priority = 90
region = "whole_recent"
visible_working = true
contains = ["esc to cancel"]
line_regex = ['^\s*(◔|◑|◕|●)\s+\p{Alphabetic}']

View File

@ -0,0 +1,37 @@
id = "opencode"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
aliases = ["open-code", "herdr:opencode"]
[[rules]]
id = "permission_required"
state = "blocked"
priority = 300
region = "whole_recent"
visible_blocker = true
any = [
{ contains = ["△ Permission required"] },
{ contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] },
]
[[rules]]
id = "interrupt_hint_working"
state = "working"
priority = 110
region = "whole_recent"
visible_working = true
any = [
{ contains = ["esc to interrupt"] },
{ contains = ["ctrl+c to interrupt"] },
{ contains = ["press esc to interrupt"] },
{ line_regex = ['(?i).*opencode.*esc (again to )?interrupt'] },
]
[[rules]]
id = "progress_bar_working"
state = "working"
priority = 100
region = "whole_recent"
visible_working = true
regex = ['(■|⬝){4,}']

View File

@ -0,0 +1,13 @@
id = "pi"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
aliases = ["herdr:pi"]
[[rules]]
id = "working_literal"
state = "working"
priority = 100
region = "whole_recent"
visible_working = true
contains = ["Working..."]

View File

@ -0,0 +1,38 @@
id = "qodercli"
version = "2026.06.10.1"
min_engine_version = 1
updated_at = "2026-06-10T00:00:00Z"
aliases = ["qoderclicn", "qoder", "qodercn"]
[[rules]]
id = "confirmation_or_input_blocker"
state = "blocked"
priority = 300
region = "whole_recent"
visible_blocker = true
any = [
{ contains = ["waiting for user confirmation"], any = [{ contains = ["yes"] }, { contains = ["no"] }, { contains = ["allow"] }, { contains = ["reject"] }] },
{ contains = ["awaiting approval"], any = [{ contains = ["allow"] }, { contains = ["reject"] }] },
{ contains = ["permission required"] },
{ contains = ["allow once or always?"] },
{ contains = ["asking user"] },
{ contains = ["enter your response"] },
{ contains = ["review your answers:"] },
{ contains = ["shell awaiting input"] },
]
[[rules]]
id = "cancel_hint_working"
state = "working"
priority = 100
region = "whole_recent"
visible_working = true
contains = ["(esc to cancel,"]
[[rules]]
id = "spinner_working"
state = "working"
priority = 90
region = "whole_recent"
visible_working = true
line_regex = ['^\s*[\u2800-\u28FF]\s+.*\p{Alphabetic}']

File diff suppressed because it is too large Load Diff

View File

@ -92,6 +92,11 @@ pub enum AppEvent {
version: String,
install_command: String,
},
/// Remote agent detection manifest update check finished.
AgentDetectionManifestsUpdated {
updated: Vec<crate::detect::manifest_update::ManifestUpdateCommit>,
status: crate::detect::manifest_update::ManifestUpdateStatus,
},
/// A pane child emitted a valid OSC 52 clipboard write. The main loop
/// re-emits it through herdr's own clipboard writer.
ClipboardWrite { content: Vec<u8> },

View File

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

View File

@ -33,11 +33,10 @@ mod xtgettcap;
use self::agent_detection::{
agent_caused_pty_activity_active, baseline_pty_causality, decide_detection_screen_read,
decide_pty_working_publish_without_screen, decide_screen_detection_publish,
detection_update_for_publish, handle_skipped_detection_update, observe_pty_output_activity,
DetectionPublishDecision, DetectionScreenReadDecision, DetectionScreenReadInput,
PendingIdleConfirmation, PendingWorkingConfirmation, PostTaintWorkingLease,
PtyCausalityTracker, PtyWorkingPublishInput, ScreenDetectionPublishInput,
decide_screen_detection_publish, detection_update_for_publish, handle_skipped_detection_update,
observe_pty_output_activity, DetectionPublishDecision, DetectionScreenReadDecision,
DetectionScreenReadInput, PendingIdleConfirmation, PendingWorkingConfirmation,
PostTaintWorkingLease, PtyCausalityTracker, ScreenDetectionPublishInput,
AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW,
};
use self::terminal::{GhosttyPaneTerminal, PaneTerminal};
@ -123,6 +122,7 @@ async fn publish_state_changed_event(
#[derive(Debug, Clone, Copy)]
struct AgentDetectionPublishUpdate {
state: AgentState,
visible_idle: bool,
visible_blocker: bool,
visible_working: bool,
process_exited: bool,
@ -135,12 +135,14 @@ async fn apply_agent_detection_publish_update(
update: AgentDetectionPublishUpdate,
observed_at: std::time::Instant,
state: &mut AgentState,
last_visible_idle: &mut bool,
last_visible_blocker: &mut bool,
last_visible_working: &mut bool,
last_visible_signal_refresh: &mut Option<std::time::Instant>,
foreground_shell_exit_reported: &mut bool,
) {
*state = update.state;
*last_visible_idle = update.visible_idle;
*last_visible_blocker = update.visible_blocker;
*last_visible_working = update.visible_working;
*last_visible_signal_refresh = if update.visible_blocker || update.visible_working {
@ -384,6 +386,7 @@ fn spawn_basic_detection_task(
terminal: Arc<PaneTerminal>,
pty_output_seq: Arc<AtomicU64>,
input_write_seq: Arc<AtomicU64>,
full_lifecycle_authority_active: Arc<AtomicBool>,
state_events: mpsc::Sender<AppEvent>,
) -> (
tokio::task::AbortHandle,
@ -398,6 +401,7 @@ fn spawn_basic_detection_task(
let handle = tokio::spawn(async move {
let mut agent_presence = AgentDetectionPresence::from_agent(None);
let mut state = AgentState::Unknown;
let mut last_visible_idle = false;
let mut last_visible_blocker = false;
let mut last_visible_working = false;
let mut last_visible_signal_refresh = None;
@ -431,6 +435,7 @@ fn spawn_basic_detection_task(
_ = detect_reset.notified() => {
agent_presence = AgentDetectionPresence::from_agent(None);
state = AgentState::Unknown;
last_visible_idle = false;
last_visible_blocker = false;
last_visible_working = false;
last_visible_signal_refresh = None;
@ -547,6 +552,7 @@ fn spawn_basic_detection_task(
input_write_seq.load(Ordering::Relaxed),
);
state = AgentState::Idle;
last_visible_idle = true;
last_visible_blocker = false;
last_visible_working = false;
last_visible_signal_refresh = None;
@ -572,6 +578,13 @@ fn spawn_basic_detection_task(
&& agent.is_some()
&& !foreground_shell_exit_reported;
if full_lifecycle_authority_active.load(Ordering::Acquire) && !process_exited {
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
continue;
}
if let Some(until) = agent_startup_grace_until {
if process_exited {
agent_startup_grace_until = None;
@ -622,76 +635,6 @@ fn spawn_basic_detection_task(
}) {
DetectionScreenReadDecision::Read => {}
DetectionScreenReadDecision::Skip => continue,
DetectionScreenReadDecision::EvaluatePtyWorking => {
match decide_pty_working_publish_without_screen(
PtyWorkingPublishInput {
agent,
current_state: state,
last_visible_blocker,
last_visible_working,
last_visible_signal_refresh,
pty_activity,
now,
},
&mut pending_idle,
&mut pending_working,
&mut post_taint_working,
) {
DetectionPublishDecision::NoPublish => {}
DetectionPublishDecision::Publish {
state: new_state,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
} => {
apply_agent_detection_publish_update(
state_events.clone(),
pane_id,
agent,
AgentDetectionPublishUpdate {
state: new_state,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
},
now,
&mut state,
&mut last_visible_blocker,
&mut last_visible_working,
&mut last_visible_signal_refresh,
&mut foreground_shell_exit_reported,
)
.await;
}
}
continue;
}
DetectionScreenReadDecision::Publish {
state: new_state,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
} => {
apply_agent_detection_publish_update(
state_events.clone(),
pane_id,
agent,
AgentDetectionPublishUpdate {
state: new_state,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
},
now,
&mut state,
&mut last_visible_blocker,
&mut last_visible_working,
&mut last_visible_signal_refresh,
&mut foreground_shell_exit_reported,
)
.await;
continue;
}
}
let content = terminal.detection_text();
@ -740,16 +683,15 @@ fn spawn_basic_detection_task(
};
match decide_screen_detection_publish(
ScreenDetectionPublishInput {
agent,
screen_detection,
current_state: state,
last_visible_idle,
last_visible_blocker,
last_visible_working,
last_visible_signal_refresh,
process_exited,
agent_changed,
pty_activity,
content: &content,
now,
},
&mut pending_idle,
@ -759,6 +701,7 @@ fn spawn_basic_detection_task(
DetectionPublishDecision::NoPublish => {}
DetectionPublishDecision::Publish {
state: new_state,
visible_idle,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
@ -769,12 +712,14 @@ fn spawn_basic_detection_task(
agent,
AgentDetectionPublishUpdate {
state: new_state,
visible_idle,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
},
now,
&mut state,
&mut last_visible_idle,
&mut last_visible_blocker,
&mut last_visible_working,
&mut last_visible_signal_refresh,
@ -854,6 +799,7 @@ pub struct PaneRuntime {
child_wait_completed: Option<Arc<AtomicBool>>,
kitty_keyboard_flags: Arc<AtomicU16>,
input_write_seq: Arc<AtomicU64>,
full_lifecycle_authority_active: Arc<AtomicBool>,
detect_reset_notify: Arc<Notify>,
pending_release: Arc<Mutex<Option<PendingAgentRelease>>>,
preserve_processes_on_drop: bool,
@ -1658,12 +1604,14 @@ impl PaneRuntime {
})?)
};
let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false));
let (detect_handle, detect_reset_notify, pending_release) = spawn_basic_detection_task(
pane_id,
child_pid.clone(),
terminal.clone(),
pty_output_seq,
input_write_seq.clone(),
full_lifecycle_authority_active.clone(),
events,
);
@ -1677,6 +1625,7 @@ impl PaneRuntime {
child_wait_completed: None,
kitty_keyboard_flags,
input_write_seq,
full_lifecycle_authority_active,
detect_reset_notify,
pending_release,
preserve_processes_on_drop: true,
@ -1727,6 +1676,7 @@ impl PaneRuntime {
let child_wait_completed = Arc::new(AtomicBool::new(false));
let input_write_seq = Arc::new(AtomicU64::new(0));
let pty_output_seq = Arc::new(AtomicU64::new(0));
let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false));
{
let child_pid = child_pid.clone();
let child_wait_completed = child_wait_completed.clone();
@ -1823,6 +1773,7 @@ impl PaneRuntime {
let state_events = events.clone();
let pty_output_seq = pty_output_seq.clone();
let input_write_seq_for_task = input_write_seq.clone();
let full_lifecycle_authority_active_for_task = full_lifecycle_authority_active.clone();
let render_notify = render_notify.clone();
let render_dirty = render_dirty.clone();
let detect_reset_notify = Arc::new(Notify::new());
@ -1834,6 +1785,7 @@ impl PaneRuntime {
let mut agent_presence =
AgentDetectionPresence::from_agent(initial_state.detected_agent);
let mut state = AgentState::Idle;
let mut last_visible_idle = initial_state.detected_agent.is_some();
let mut last_process_check = Instant::now();
let mut last_foreground_pgid = None;
let mut has_process_probe = false;
@ -1877,6 +1829,7 @@ impl PaneRuntime {
_ = detect_reset.notified() => {
agent_presence = AgentDetectionPresence::from_agent(None);
state = AgentState::Unknown;
last_visible_idle = false;
last_foreground_pgid = None;
has_process_probe = false;
acquisition_started_at = None;
@ -2001,6 +1954,7 @@ impl PaneRuntime {
input_write_seq_for_task.load(Ordering::Relaxed),
);
state = AgentState::Idle;
last_visible_idle = true;
last_visible_blocker = false;
last_visible_working = false;
last_visible_signal_refresh = None;
@ -2055,6 +2009,15 @@ impl PaneRuntime {
&& agent.is_some()
&& !foreground_shell_exit_reported;
if full_lifecycle_authority_active_for_task.load(Ordering::Acquire)
&& !process_exited
{
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
continue;
}
if let Some(until) = agent_startup_grace_until {
if process_exited {
agent_startup_grace_until = None;
@ -2105,76 +2068,6 @@ impl PaneRuntime {
}) {
DetectionScreenReadDecision::Read => {}
DetectionScreenReadDecision::Skip => continue,
DetectionScreenReadDecision::EvaluatePtyWorking => {
match decide_pty_working_publish_without_screen(
PtyWorkingPublishInput {
agent,
current_state: state,
last_visible_blocker,
last_visible_working,
last_visible_signal_refresh,
pty_activity,
now,
},
&mut pending_idle,
&mut pending_working,
&mut post_taint_working,
) {
DetectionPublishDecision::NoPublish => {}
DetectionPublishDecision::Publish {
state: new_state,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
} => {
apply_agent_detection_publish_update(
state_events.clone(),
pane_id,
agent,
AgentDetectionPublishUpdate {
state: new_state,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
},
now,
&mut state,
&mut last_visible_blocker,
&mut last_visible_working,
&mut last_visible_signal_refresh,
&mut foreground_shell_exit_reported,
)
.await;
}
}
continue;
}
DetectionScreenReadDecision::Publish {
state: new_state,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
} => {
apply_agent_detection_publish_update(
state_events.clone(),
pane_id,
agent,
AgentDetectionPublishUpdate {
state: new_state,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
},
now,
&mut state,
&mut last_visible_blocker,
&mut last_visible_working,
&mut last_visible_signal_refresh,
&mut foreground_shell_exit_reported,
)
.await;
continue;
}
}
let content = terminal.detection_text();
@ -2223,16 +2116,15 @@ impl PaneRuntime {
};
match decide_screen_detection_publish(
ScreenDetectionPublishInput {
agent,
screen_detection,
current_state: state,
last_visible_idle,
last_visible_blocker,
last_visible_working,
last_visible_signal_refresh,
process_exited,
agent_changed,
pty_activity,
content: &content,
now,
},
&mut pending_idle,
@ -2242,6 +2134,7 @@ impl PaneRuntime {
DetectionPublishDecision::NoPublish => {}
DetectionPublishDecision::Publish {
state: new_state,
visible_idle,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
@ -2252,12 +2145,14 @@ impl PaneRuntime {
agent,
AgentDetectionPublishUpdate {
state: new_state,
visible_idle,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
},
now,
&mut state,
&mut last_visible_idle,
&mut last_visible_blocker,
&mut last_visible_working,
&mut last_visible_signal_refresh,
@ -2281,6 +2176,7 @@ impl PaneRuntime {
child_wait_completed: Some(child_wait_completed),
kitty_keyboard_flags,
input_write_seq,
full_lifecycle_authority_active,
detect_reset_notify,
pending_release,
preserve_processes_on_drop: false,
@ -2298,6 +2194,23 @@ impl PaneRuntime {
self.detect_reset_notify.notify_one();
}
pub fn reset_agent_detection(&self) {
self.detect_reset_notify.notify_one();
}
#[cfg(test)]
pub(crate) fn agent_detection_reset_notify_for_test(&self) -> Arc<Notify> {
self.detect_reset_notify.clone()
}
pub fn set_full_lifecycle_authority_active(&self, active: bool) {
self.full_lifecycle_authority_active
.store(active, Ordering::Release);
if active {
self.detect_reset_notify.notify_one();
}
}
pub(crate) fn current_size(&self) -> (u16, u16) {
let (rows, cols, _, _) = self.current_size.get();
(rows, cols)
@ -2389,6 +2302,10 @@ impl PaneRuntime {
self.terminal.visible_ansi()
}
pub fn detection_text(&self) -> String {
self.terminal.detection_text()
}
pub fn recent_text(&self, lines: usize) -> String {
self.terminal.recent_text(lines)
}
@ -2665,6 +2582,7 @@ impl PaneRuntime {
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
input_write_seq: Arc::new(AtomicU64::new(0)),
full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
preserve_processes_on_drop: true,
@ -3038,6 +2956,7 @@ mod tests {
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
input_write_seq: Arc::new(AtomicU64::new(0)),
full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
preserve_processes_on_drop: true,
@ -3068,6 +2987,7 @@ mod tests {
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
input_write_seq: Arc::new(AtomicU64::new(0)),
full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
preserve_processes_on_drop: true,

View File

@ -6,7 +6,6 @@ pub(super) const AGENT_PTY_ACTIVITY_WINDOW: std::time::Duration =
std::time::Duration::from_millis(1800);
pub(super) const AGENT_INPUT_TAINT_WINDOW: std::time::Duration =
std::time::Duration::from_millis(1200);
pub(super) const AGENT_POST_TAINT_WORKING_LEASE: std::time::Duration = AGENT_PTY_ACTIVITY_WINDOW;
pub(super) const AGENT_PENDING_IDLE_RECHECK: std::time::Duration =
std::time::Duration::from_millis(100);
const AGENT_PENDING_IDLE_CONFIRMATIONS: u8 = 3;
@ -29,6 +28,7 @@ pub(super) const AGENT_STARTUP_GRACE_WINDOW: std::time::Duration =
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct DetectionPublishState {
pub(super) state: AgentState,
pub(super) visible_idle: bool,
pub(super) visible_blocker: bool,
pub(super) visible_working: bool,
}
@ -55,11 +55,12 @@ impl PendingIdleConfirmation {
next: DetectionPublishState,
agent_changed: bool,
process_exited: bool,
pty_signal: Option<PtyActivitySignal>,
_pty_signal: Option<PtyActivitySignal>,
now: std::time::Instant,
) -> bool {
let is_working_to_plain_idle = previous.state == AgentState::Working
&& next.state == AgentState::Idle
&& !next.visible_idle
&& !next.visible_blocker
&& !agent_changed
&& !process_exited;
@ -69,11 +70,6 @@ impl PendingIdleConfirmation {
return false;
}
if pty_signal.is_some_and(|signal| !signal.active && !signal.tainted) {
self.clear();
return false;
}
let Some(started_at) = self.started_at else {
self.started_at = Some(now);
self.confirmations = 0;
@ -142,6 +138,11 @@ impl PendingWorkingConfirmation {
return false;
}
if next.visible_working {
self.clear();
return false;
}
let Some(pty_signal) = pty_signal else {
self.clear();
return false;
@ -202,57 +203,19 @@ impl PostTaintWorkingLease {
self.until.is_some()
}
pub(super) fn start(&mut self, now: std::time::Instant) {
self.until = Some(now + AGENT_POST_TAINT_WORKING_LEASE);
}
pub(super) fn clear(&mut self) {
self.until = None;
}
pub(super) fn should_hold_working_to_idle(
&mut self,
previous: DetectionPublishState,
next: DetectionPublishState,
agent_changed: bool,
process_exited: bool,
pty_signal: Option<PtyActivitySignal>,
now: std::time::Instant,
_previous: DetectionPublishState,
_next: DetectionPublishState,
_agent_changed: bool,
_process_exited: bool,
_pty_signal: Option<PtyActivitySignal>,
_now: std::time::Instant,
) -> bool {
let is_working_to_plain_idle = previous.state == AgentState::Working
&& next.state == AgentState::Idle
&& !next.visible_blocker
&& !agent_changed
&& !process_exited;
if !is_working_to_plain_idle {
self.clear();
return false;
}
let Some(pty_signal) = pty_signal else {
self.clear();
return false;
};
if pty_signal.active || pty_signal.tainted {
self.clear();
return false;
}
if pty_signal.taint_just_ended {
self.start(now);
return true;
}
let Some(until) = self.until else {
return false;
};
if now < until {
return true;
}
self.clear();
false
}
@ -297,13 +260,6 @@ pub(super) fn should_skip_idle_screen_scan(input: IdleScreenScanSkipInput) -> bo
pub(super) enum DetectionScreenReadDecision {
Read,
Skip,
EvaluatePtyWorking,
Publish {
state: AgentState,
visible_blocker: bool,
visible_working: bool,
process_exited: bool,
},
}
#[derive(Debug, Clone, Copy)]
@ -319,42 +275,9 @@ pub(super) struct DetectionScreenReadInput {
pub(super) last_screen_scan_pty_output_seq: Option<u64>,
}
fn agent_activity_veto_requires_screen(agent: Option<Agent>) -> bool {
matches!(agent, Some(Agent::Claude))
}
pub(super) fn decide_detection_screen_read(
input: DetectionScreenReadInput,
) -> DetectionScreenReadDecision {
if !input.agent_changed
&& !input.process_exited
&& !input.pending_idle_active
&& !input.post_taint_working_active
&& input.agent.is_some()
&& input
.pty_activity
.is_some_and(|signal| signal.active && !signal.tainted)
{
return match input.state {
AgentState::Working => DetectionScreenReadDecision::Skip,
AgentState::Blocked => DetectionScreenReadDecision::Publish {
state: AgentState::Working,
visible_blocker: false,
visible_working: false,
process_exited: false,
},
AgentState::Idle | AgentState::Unknown
if agent_activity_veto_requires_screen(input.agent)
&& !input.pending_working_active =>
{
DetectionScreenReadDecision::Read
}
AgentState::Idle | AgentState::Unknown => {
DetectionScreenReadDecision::EvaluatePtyWorking
}
};
}
if should_skip_idle_screen_scan(IdleScreenScanSkipInput {
state: input.state,
agent: input.agent,
@ -372,18 +295,6 @@ pub(super) fn decide_detection_screen_read(
}
}
pub(super) fn pty_working_transition_is_vetoed(
agent: Option<Agent>,
previous: DetectionPublishState,
next: DetectionPublishState,
content: &str,
) -> bool {
previous.state == AgentState::Idle
&& next.state == AgentState::Working
&& !next.visible_blocker
&& crate::detect::agent_activity_veto(agent, content).is_some()
}
pub(super) fn should_publish_detection_update(
previous: DetectionPublishState,
next: DetectionPublishState,
@ -392,6 +303,7 @@ pub(super) fn should_publish_detection_update(
stable_visible_signal_refresh_due: bool,
) -> bool {
next.state != previous.state
|| next.visible_idle != previous.visible_idle
|| next.visible_blocker != previous.visible_blocker
|| next.visible_working != previous.visible_working
|| agent_changed
@ -421,36 +333,22 @@ pub(super) enum DetectionTransitionDecision {
}
#[derive(Debug, Clone, Copy)]
pub(super) struct DetectionTransitionInput<'a> {
pub(super) agent: Option<Agent>,
pub(super) struct DetectionTransitionInput {
pub(super) previous_publish: DetectionPublishState,
pub(super) next_publish: DetectionPublishState,
pub(super) agent_changed: bool,
pub(super) process_exited: bool,
pub(super) pty_activity: Option<PtyActivitySignal>,
pub(super) stable_refresh_due: bool,
pub(super) content: &'a str,
pub(super) now: std::time::Instant,
}
pub(super) fn decide_detection_transition(
input: DetectionTransitionInput<'_>,
input: DetectionTransitionInput,
pending_idle: &mut PendingIdleConfirmation,
pending_working: &mut PendingWorkingConfirmation,
post_taint_working: &mut PostTaintWorkingLease,
) -> DetectionTransitionDecision {
if pty_working_transition_is_vetoed(
input.agent,
input.previous_publish,
input.next_publish,
input.content,
) {
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
return DetectionTransitionDecision::NoPublish;
}
if pending_working.should_publish_held_working_before_exit(
input.previous_publish,
input.next_publish,
@ -518,6 +416,7 @@ pub(super) enum DetectionPublishDecision {
NoPublish,
Publish {
state: AgentState,
visible_idle: bool,
visible_blocker: bool,
visible_working: bool,
process_exited: bool,
@ -525,9 +424,9 @@ pub(super) enum DetectionPublishDecision {
}
#[derive(Debug, Clone, Copy)]
pub(super) struct ScreenDetectionPublishInput<'a> {
pub(super) agent: Option<Agent>,
pub(super) struct ScreenDetectionPublishInput {
pub(super) current_state: AgentState,
pub(super) last_visible_idle: bool,
pub(super) last_visible_blocker: bool,
pub(super) last_visible_working: bool,
pub(super) last_visible_signal_refresh: Option<std::time::Instant>,
@ -535,118 +434,40 @@ pub(super) struct ScreenDetectionPublishInput<'a> {
pub(super) process_exited: bool,
pub(super) agent_changed: bool,
pub(super) pty_activity: Option<PtyActivitySignal>,
pub(super) content: &'a str,
pub(super) now: std::time::Instant,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct PtyWorkingPublishInput {
pub(super) agent: Option<Agent>,
pub(super) current_state: AgentState,
pub(super) last_visible_blocker: bool,
pub(super) last_visible_working: bool,
pub(super) last_visible_signal_refresh: Option<std::time::Instant>,
pub(super) pty_activity: Option<PtyActivitySignal>,
pub(super) now: std::time::Instant,
}
pub(super) fn decide_pty_working_publish_without_screen(
input: PtyWorkingPublishInput,
pending_idle: &mut PendingIdleConfirmation,
pending_working: &mut PendingWorkingConfirmation,
post_taint_working: &mut PostTaintWorkingLease,
) -> DetectionPublishDecision {
let previous_publish = DetectionPublishState {
state: input.current_state,
visible_blocker: input.last_visible_blocker,
visible_working: input.last_visible_working,
};
let next_publish = DetectionPublishState {
state: AgentState::Working,
visible_blocker: false,
visible_working: false,
};
let stable_refresh_due = stable_visible_signal_refresh_due(
previous_publish,
next_publish,
input.last_visible_signal_refresh,
input.now,
);
match decide_detection_transition(
DetectionTransitionInput {
agent: input.agent,
previous_publish,
next_publish,
agent_changed: false,
process_exited: false,
pty_activity: input.pty_activity,
stable_refresh_due,
content: "",
now: input.now,
},
pending_idle,
pending_working,
post_taint_working,
) {
DetectionTransitionDecision::NoPublish => DetectionPublishDecision::NoPublish,
DetectionTransitionDecision::PublishHeldWorkingBeforeExit => {
DetectionPublishDecision::Publish {
state: AgentState::Working,
visible_blocker: false,
visible_working: false,
process_exited: false,
}
}
DetectionTransitionDecision::PublishNext => DetectionPublishDecision::Publish {
state: AgentState::Working,
visible_blocker: false,
visible_working: false,
process_exited: false,
},
}
}
pub(super) fn decide_screen_detection_publish(
input: ScreenDetectionPublishInput<'_>,
input: ScreenDetectionPublishInput,
pending_idle: &mut PendingIdleConfirmation,
pending_working: &mut PendingWorkingConfirmation,
post_taint_working: &mut PostTaintWorkingLease,
) -> DetectionPublishDecision {
let pty_signal = input
.pty_activity
.map(|signal| crate::agent_detection_policy::PtySignal {
active: signal.active,
tainted: signal.tainted,
});
let detection = match crate::agent_detection_policy::apply_detection_policy(
crate::agent_detection_policy::DetectionPolicyInput {
agent: input.agent,
screen_detection: input.screen_detection,
process_exited: input.process_exited,
startup_grace_active: false,
pty_signal,
},
) {
crate::agent_detection_policy::DetectionPolicyDecision::Publish(detection) => detection,
crate::agent_detection_policy::DetectionPolicyDecision::Freeze => {
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
return DetectionPublishDecision::NoPublish;
}
};
let detection = input.screen_detection;
let new_state = crate::terminal::state::stabilize_agent_detection(detection);
let visible_idle = detection.visible_idle && new_state == AgentState::Idle;
let visible_blocker = detection.visible_blocker && new_state == AgentState::Blocked;
let visible_working = detection.visible_working && new_state == AgentState::Working;
if input.pty_activity.is_some_and(|signal| signal.tainted)
&& new_state == AgentState::Idle
&& !input.process_exited
{
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
return DetectionPublishDecision::NoPublish;
}
let previous_publish = DetectionPublishState {
state: input.current_state,
visible_idle: input.last_visible_idle,
visible_blocker: input.last_visible_blocker,
visible_working: input.last_visible_working,
};
let next_publish = DetectionPublishState {
state: new_state,
visible_idle,
visible_blocker,
visible_working,
};
@ -659,14 +480,12 @@ pub(super) fn decide_screen_detection_publish(
match decide_detection_transition(
DetectionTransitionInput {
agent: input.agent,
previous_publish,
next_publish,
agent_changed: input.agent_changed,
process_exited: input.process_exited,
pty_activity: input.pty_activity,
stable_refresh_due,
content: input.content,
now: input.now,
},
pending_idle,
@ -677,6 +496,7 @@ pub(super) fn decide_screen_detection_publish(
DetectionTransitionDecision::PublishHeldWorkingBeforeExit => {
DetectionPublishDecision::Publish {
state: AgentState::Working,
visible_idle: false,
visible_blocker: false,
visible_working: false,
process_exited: false,
@ -684,6 +504,7 @@ pub(super) fn decide_screen_detection_publish(
}
DetectionTransitionDecision::PublishNext => DetectionPublishDecision::Publish {
state: new_state,
visible_idle,
visible_blocker,
visible_working,
process_exited: input.process_exited,
@ -696,14 +517,11 @@ pub(super) fn detection_update_for_publish(
content: &str,
process_exited: bool,
) -> Option<crate::detect::AgentDetection> {
if crate::detect::should_skip_state_update(agent, content) {
return None;
}
if process_exited {
return Some(crate::detect::AgentDetection {
state: AgentState::Idle,
skip_state_update: false,
visible_idle: true,
visible_blocker: false,
visible_working: false,
});
@ -811,17 +629,14 @@ fn consume_skipped_pty_causality(
pub(super) fn handle_skipped_detection_update(
state: AgentState,
pty_signal: Option<PtyActivitySignal>,
_pty_signal: Option<PtyActivitySignal>,
post_taint_working: &mut PostTaintWorkingLease,
tracker: &mut PtyCausalityTracker,
pty_output_seq: u64,
input_seq: u64,
now: std::time::Instant,
_now: std::time::Instant,
) {
if state == AgentState::Working {
if pty_signal.is_some_and(|signal| signal.taint_just_ended) {
post_taint_working.start(now);
}
return;
}
@ -836,6 +651,7 @@ mod tests {
fn publish_state(state: AgentState) -> DetectionPublishState {
DetectionPublishState {
state,
visible_idle: false,
visible_blocker: false,
visible_working: false,
}
@ -876,16 +692,14 @@ mod tests {
next_publish: DetectionPublishState,
pty_activity: Option<PtyActivitySignal>,
now: std::time::Instant,
) -> DetectionTransitionInput<'static> {
) -> DetectionTransitionInput {
DetectionTransitionInput {
agent: Some(Agent::Codex),
previous_publish,
next_publish,
agent_changed: false,
process_exited: false,
pty_activity,
stable_refresh_due: false,
content: "",
now,
}
}
@ -894,6 +708,7 @@ mod tests {
AgentDetection {
state,
skip_state_update: false,
visible_idle: state == AgentState::Idle,
visible_blocker: false,
visible_working: state == AgentState::Working,
}
@ -904,10 +719,10 @@ mod tests {
screen_detection: AgentDetection,
pty_activity: Option<PtyActivitySignal>,
now: std::time::Instant,
) -> ScreenDetectionPublishInput<'static> {
) -> ScreenDetectionPublishInput {
ScreenDetectionPublishInput {
agent: Some(Agent::Codex),
current_state,
last_visible_idle: false,
last_visible_blocker: false,
last_visible_working: false,
last_visible_signal_refresh: None,
@ -915,7 +730,6 @@ mod tests {
process_exited: false,
agent_changed: false,
pty_activity,
content: "",
now,
}
}
@ -967,7 +781,7 @@ mod tests {
AgentState::Idle,
pty_activity(true, true, 10),
)),
DetectionScreenReadDecision::EvaluatePtyWorking
DetectionScreenReadDecision::Read
);
assert_eq!(
decide_detection_screen_read(screen_read_input(
@ -979,13 +793,13 @@ mod tests {
}
#[test]
fn screen_read_decision_handles_active_pty_without_screen_for_non_idle_states() {
fn screen_read_decision_reads_screen_for_active_pty() {
assert_eq!(
decide_detection_screen_read(screen_read_input(
AgentState::Working,
pty_activity(true, true, 11),
)),
DetectionScreenReadDecision::Skip
DetectionScreenReadDecision::Read
);
assert_eq!(
@ -993,12 +807,7 @@ mod tests {
AgentState::Blocked,
pty_activity(true, true, 11),
)),
DetectionScreenReadDecision::Publish {
state: AgentState::Working,
visible_blocker: false,
visible_working: false,
process_exited: false,
}
DetectionScreenReadDecision::Read
);
assert_eq!(
@ -1006,7 +815,7 @@ mod tests {
AgentState::Idle,
pty_activity(true, true, 11),
)),
DetectionScreenReadDecision::EvaluatePtyWorking
DetectionScreenReadDecision::Read
);
assert_eq!(
@ -1044,12 +853,12 @@ mod tests {
}
#[test]
fn screen_read_decision_keeps_active_pty_pending_working_screenless() {
fn screen_read_decision_reads_during_active_pty_pending_working() {
let mut input = screen_read_input(AgentState::Idle, pty_activity(true, true, 11));
input.pending_working_active = true;
assert_eq!(
decide_detection_screen_read(input),
DetectionScreenReadDecision::EvaluatePtyWorking
DetectionScreenReadDecision::Read
);
let mut input = screen_read_input_for_agent(
@ -1060,7 +869,7 @@ mod tests {
input.pending_working_active = true;
assert_eq!(
decide_detection_screen_read(input),
DetectionScreenReadDecision::EvaluatePtyWorking
DetectionScreenReadDecision::Read
);
}
@ -1072,10 +881,14 @@ mod tests {
}
#[test]
fn codex_transcript_viewer_suppresses_process_exit_idle_publish() {
fn process_exit_overrides_transcript_viewer_skip() {
let content = "/ T R A N S C R I P T /\n\n yeah go ahead\n────────────────────────────────────────────────────────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev";
assert!(detection_update_for_publish(Some(Agent::Codex), content, true).is_none());
let detection = detection_update_for_publish(Some(Agent::Codex), content, true)
.expect("process exit should publish idle even inside transcript viewer");
assert_eq!(detection.state, AgentState::Idle);
assert!(detection.visible_idle);
assert!(!detection.skip_state_update);
}
#[test]
@ -1093,6 +906,7 @@ mod tests {
let now = std::time::Instant::now();
let previous = DetectionPublishState {
state: AgentState::Idle,
visible_idle: false,
visible_blocker: false,
visible_working: false,
};
@ -1117,6 +931,7 @@ mod tests {
let now = std::time::Instant::now();
let previous = DetectionPublishState {
state: AgentState::Working,
visible_idle: false,
visible_blocker: false,
visible_working: true,
};
@ -1141,6 +956,7 @@ mod tests {
let now = std::time::Instant::now();
let previous = DetectionPublishState {
state: AgentState::Blocked,
visible_idle: false,
visible_blocker: true,
visible_working: false,
};
@ -1233,13 +1049,13 @@ mod tests {
}
#[test]
fn pending_idle_does_not_extend_pty_quiet_lease() {
fn pending_idle_holds_plain_idle_fallback_even_when_pty_is_quiet() {
let now = std::time::Instant::now();
let previous = publish_state(AgentState::Working);
let idle = publish_state(AgentState::Idle);
let mut pending = PendingIdleConfirmation::default();
assert!(!pending.should_hold_working_to_idle(
assert!(pending.should_hold_working_to_idle(
previous,
idle,
false,
@ -1247,17 +1063,17 @@ mod tests {
Some(pty_activity(false, false, 10)),
now
));
assert!(!pending.active());
assert!(pending.active());
}
#[test]
fn post_taint_lease_holds_existing_working_before_idle_fallback() {
fn post_taint_lease_never_holds_working_to_idle() {
let now = std::time::Instant::now();
let previous = publish_state(AgentState::Working);
let idle = publish_state(AgentState::Idle);
let mut lease = PostTaintWorkingLease::default();
assert!(lease.should_hold_working_to_idle(
assert!(!lease.should_hold_working_to_idle(
previous,
idle,
false,
@ -1265,22 +1081,7 @@ mod tests {
Some(pty_activity_after_taint(10)),
now
));
assert!(lease.should_hold_working_to_idle(
previous,
idle,
false,
false,
Some(pty_activity(false, false, 10)),
now + AGENT_POST_TAINT_WORKING_LEASE - std::time::Duration::from_millis(1)
));
assert!(!lease.should_hold_working_to_idle(
previous,
idle,
false,
false,
Some(pty_activity(false, false, 10)),
now + AGENT_POST_TAINT_WORKING_LEASE + std::time::Duration::from_millis(1)
));
assert!(!lease.active());
}
#[test]
@ -1627,33 +1428,6 @@ mod tests {
assert_eq!(seq.load(Ordering::Relaxed), 2);
}
#[test]
fn claude_recap_veto_only_applies_to_idle_to_working() {
let screen =
"※ recap: Done. (disable recaps in /config)\n\n─────────────\n \n─────────────";
let idle = publish_state(AgentState::Idle);
let working = publish_state(AgentState::Working);
assert!(pty_working_transition_is_vetoed(
Some(Agent::Claude),
idle,
working,
screen
));
assert!(!pty_working_transition_is_vetoed(
Some(Agent::Claude),
working,
idle,
screen
));
assert!(!pty_working_transition_is_vetoed(
Some(Agent::Codex),
idle,
working,
screen
));
}
#[test]
fn pty_activity_outside_taint_reports_active_until_hold_expires() {
let now = std::time::Instant::now();
@ -1773,7 +1547,8 @@ mod tests {
let previous = publish_state(AgentState::Working);
let idle = publish_state(AgentState::Idle);
assert!(lease.should_hold_working_to_idle(
assert!(!lease.active());
assert!(!lease.should_hold_working_to_idle(
previous,
idle,
false,
@ -1912,11 +1687,11 @@ mod tests {
),
DetectionTransitionDecision::NoPublish
);
assert!(post_taint_working.active());
assert!(!post_taint_working.active());
}
#[test]
fn screen_publish_prefers_active_pty_working_over_screen_blocker() {
fn screen_publish_keeps_visible_blocker_during_active_pty() {
let now = std::time::Instant::now();
let mut pending_idle = PendingIdleConfirmation::default();
let mut pending_working = PendingWorkingConfirmation::default();
@ -1937,8 +1712,9 @@ mod tests {
&mut post_taint_working,
),
DetectionPublishDecision::Publish {
state: AgentState::Working,
visible_blocker: false,
state: AgentState::Blocked,
visible_idle: false,
visible_blocker: true,
visible_working: false,
process_exited: false,
}
@ -1946,7 +1722,7 @@ mod tests {
}
#[test]
fn screen_publish_downgrades_visible_working_to_idle_when_pty_is_quiet() {
fn screen_publish_keeps_visible_working_when_pty_is_quiet() {
let now = std::time::Instant::now();
let mut pending_idle = PendingIdleConfirmation::default();
let mut pending_working = PendingWorkingConfirmation::default();
@ -1965,16 +1741,46 @@ mod tests {
&mut post_taint_working,
),
DetectionPublishDecision::Publish {
state: AgentState::Idle,
state: AgentState::Working,
visible_idle: false,
visible_blocker: false,
visible_working: false,
visible_working: true,
process_exited: false,
}
);
}
#[test]
fn screen_publish_freezes_during_taint() {
fn screen_publish_keeps_visible_working_during_active_pty() {
let now = std::time::Instant::now();
let mut pending_idle = PendingIdleConfirmation::default();
let mut pending_working = PendingWorkingConfirmation::default();
let mut post_taint_working = PostTaintWorkingLease::default();
assert_eq!(
decide_screen_detection_publish(
screen_publish_input(
AgentState::Idle,
screen_detection(AgentState::Working),
Some(pty_activity(true, true, 10)),
now,
),
&mut pending_idle,
&mut pending_working,
&mut post_taint_working,
),
DetectionPublishDecision::Publish {
state: AgentState::Working,
visible_idle: false,
visible_blocker: false,
visible_working: true,
process_exited: false,
}
);
}
#[test]
fn screen_publish_freezes_idle_during_taint() {
let now = std::time::Instant::now();
let mut pending_idle = PendingIdleConfirmation::default();
let mut pending_working = PendingWorkingConfirmation::default();

View File

@ -3359,6 +3359,14 @@ impl HeadlessServer {
self.app.run_auto_update_check();
}
if self
.app
.next_agent_manifest_update_check
.is_some_and(|deadline| now >= deadline)
{
self.app.run_agent_manifest_update_check();
}
if self
.app
.session_save_deadline
@ -4766,6 +4774,16 @@ next_tab = ""
}));
}
#[test]
fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() {
let mut server = test_headless_server();
let now = Instant::now();
server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1));
assert!(!server.handle_scheduled_tasks_headless(now, false));
assert_eq!(server.app.next_agent_manifest_update_check, None);
}
#[tokio::test]
async fn headless_scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() {
let mut server = test_headless_server();

View File

@ -197,6 +197,21 @@ impl TerminalRuntime {
self.0.begin_graceful_release(agent);
}
pub fn reset_agent_detection(&self) {
self.0.reset_agent_detection();
}
#[cfg(test)]
pub(crate) fn agent_detection_reset_notify_for_test(
&self,
) -> std::sync::Arc<tokio::sync::Notify> {
self.0.agent_detection_reset_notify_for_test()
}
pub fn set_full_lifecycle_authority_active(&self, active: bool) {
self.0.set_full_lifecycle_authority_active(active);
}
pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) {
self.0.resize(rows, cols, cell_width_px, cell_height_px);
}
@ -250,6 +265,10 @@ impl TerminalRuntime {
self.0.visible_ansi()
}
pub fn detection_text(&self) -> String {
self.0.detection_text()
}
pub fn recent_text(&self, lines: usize) -> String {
self.0.recent_text(lines)
}

View File

@ -31,6 +31,7 @@ pub struct HookAuthority {
struct SuppressedFullLifecycleHookReport {
agent_label: String,
session_ref: Option<crate::agent_resume::AgentSessionRef>,
observed_at: Instant,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -173,7 +174,7 @@ impl TerminalState {
agent: Option<Agent>,
fallback_state: AgentState,
visible_blocker: bool,
_ignored_screen_idle: bool,
_visible_idle: bool,
_visible_working: bool,
process_exited: bool,
now: Instant,
@ -205,6 +206,19 @@ impl TerminalState {
!= self.current_session_identity_for_persistence(),
};
}
if !process_exited && self.detected_state_observed_before_release_suppression(agent, now) {
return TerminalStateMutation {
effective_state_change: self.recompute_effective_state(
previous_agent_label,
previous_known_agent,
previous_state,
previous_presentation,
now,
),
session_ref_changed: previous_session
!= self.current_session_identity_for_persistence(),
};
}
self.detected_agent = agent;
if !process_exited {
self.clear_full_lifecycle_hook_suppression_for_detected_agent(
@ -448,7 +462,7 @@ impl TerminalState {
.hook_authority
.as_ref()
.filter(|authority| {
crate::agent_detection_policy::full_lifecycle_hook_authority(
crate::detect::full_lifecycle_hook_authority(
&authority.source,
&authority.agent_label,
)
@ -464,13 +478,14 @@ impl TerminalState {
SuppressedFullLifecycleHookReport {
agent_label,
session_ref,
observed_at: Instant::now(),
},
);
}
}
fn suppress_full_lifecycle_hook_report(&mut self, source: &str, agent_label: &str) {
if crate::agent_detection_policy::full_lifecycle_hook_authority(source, agent_label) {
if crate::detect::full_lifecycle_hook_authority(source, agent_label) {
self.suppressed_full_lifecycle_hook_reports.insert(
source.to_string(),
SuppressedFullLifecycleHookReport {
@ -479,6 +494,7 @@ impl TerminalState {
.hook_authority
.as_ref()
.and_then(|authority| authority.session_ref.clone()),
observed_at: Instant::now(),
},
);
}
@ -490,7 +506,7 @@ impl TerminalState {
agent_label: &str,
session_ref: &Option<crate::agent_resume::AgentSessionRef>,
) -> bool {
if !crate::agent_detection_policy::full_lifecycle_hook_authority(source, agent_label) {
if !crate::detect::full_lifecycle_hook_authority(source, agent_label) {
return false;
}
self.suppressed_full_lifecycle_hook_reports
@ -517,10 +533,8 @@ impl TerminalState {
let Some(authority) = self.hook_authority.as_ref() else {
return false;
};
if !crate::agent_detection_policy::full_lifecycle_hook_authority(
&authority.source,
&authority.agent_label,
) {
if !crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label)
{
return false;
}
if authority.source != source || authority.agent_label != agent_label {
@ -550,6 +564,22 @@ impl TerminalState {
});
}
fn detected_state_observed_before_release_suppression(
&self,
detected_agent: Option<Agent>,
observed_at: Instant,
) -> bool {
let Some(detected_agent) = detected_agent else {
return false;
};
self.suppressed_full_lifecycle_hook_reports
.values()
.any(|suppressed| {
crate::detect::parse_agent_label(&suppressed.agent_label) == Some(detected_agent)
&& observed_at <= suppressed.observed_at
})
}
fn current_session_identity_for_persistence(
&self,
) -> Option<(
@ -791,6 +821,10 @@ impl TerminalState {
self.detected_agent
}
pub fn full_lifecycle_hook_authority_active(&self) -> bool {
self.live_full_lifecycle_hook_authority()
}
fn visible_blocker_overrides_hook(&self) -> bool {
if self.live_full_lifecycle_hook_authority() {
return false;
@ -806,10 +840,7 @@ impl TerminalState {
fn live_full_lifecycle_hook_authority(&self) -> bool {
self.hook_authority.as_ref().is_some_and(|authority| {
crate::agent_detection_policy::full_lifecycle_hook_authority(
&authority.source,
&authority.agent_label,
)
crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label)
})
}
@ -935,6 +966,7 @@ mod tests {
let detection = AgentDetection {
state: AgentState::Idle,
skip_state_update: false,
visible_idle: false,
visible_blocker: false,
visible_working: false,
};
@ -1084,6 +1116,39 @@ mod tests {
assert_eq!(terminal.state, AgentState::Idle);
}
#[test]
fn process_exit_clears_omp_full_lifecycle_hook_authority_without_known_agent() {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_hook_authority_with_custom_status_at(
"herdr:omp".into(),
"omp".into(),
AgentState::Working,
None,
None,
None,
Some(10),
now,
);
let change = terminal.set_detected_state_with_screen_signals_at(
None,
AgentState::Idle,
false,
true,
false,
true,
now + Duration::from_millis(1),
);
assert!(terminal.hook_authority.is_none());
assert_eq!(terminal.state, AgentState::Idle);
assert_eq!(
change.effective_state_change.unwrap().previous_state,
AgentState::Working
);
}
#[test]
fn late_full_lifecycle_hook_after_process_exit_does_not_reacquire_authority() {
let now = Instant::now();
@ -1291,7 +1356,6 @@ mod tests {
#[test]
fn fresh_detected_process_allows_full_lifecycle_hook_after_suppression() {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
terminal.set_hook_authority(
@ -1302,6 +1366,7 @@ mod tests {
Some(20),
);
terminal.release_agent("herdr:pi", "pi", Some(21));
let now = Instant::now();
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
@ -1325,6 +1390,43 @@ mod tests {
assert_eq!(terminal.state, AgentState::Working);
}
#[test]
fn release_suppression_ignores_same_agent_idle_publish() {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
terminal.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
Some(20),
);
terminal.release_agent("herdr:pi", "pi", Some(21));
let change = terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
AgentState::Idle,
false,
true,
false,
false,
now,
);
let late = terminal.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
Some(22),
);
assert!(change.effective_state_change.is_none());
assert!(late.is_none());
assert_eq!(terminal.detected_agent, None);
assert_eq!(terminal.state, AgentState::Unknown);
}
#[test]
fn fresh_session_ref_allows_full_lifecycle_hook_after_suppression() {
let mut terminal = test_terminal();

View File

@ -194,11 +194,13 @@ pub(crate) fn settings_primary_button_label(
}
pub(crate) fn settings_show_primary_action(app: &AppState) -> bool {
app.settings.section != crate::app::state::SettingsSection::Integrations
|| app
match app.settings.section {
crate::app::state::SettingsSection::Integrations => app
.integration_recommendations
.iter()
.any(crate::integration::IntegrationRecommendation::needs_install)
.any(crate::integration::IntegrationRecommendation::needs_install),
_ => true,
}
}
pub(crate) fn settings_button_rects(

View File

@ -253,6 +253,37 @@ where
}
}
#[cfg(not(target_os = "macos"))]
fn wait_for_events(
reader: &mut JsonLineReader,
expected: &[&str],
timeout: Duration,
) -> Vec<serde_json::Value> {
let deadline = Instant::now() + timeout;
let mut remaining = expected.to_vec();
let mut events = Vec::new();
while !remaining.is_empty() {
let remaining_timeout = deadline.saturating_duration_since(Instant::now());
let value = reader.read_json_line(remaining_timeout.max(Duration::from_millis(1)));
let Some(event) = value["event"].as_str() else {
continue;
};
if let Some(index) = remaining.iter().position(|expected| *expected == event) {
remaining.remove(index);
events.push(value);
}
}
events
}
#[cfg(not(target_os = "macos"))]
fn event_by_kind<'a>(events: &'a [serde_json::Value], kind: &str) -> &'a serde_json::Value {
events
.iter()
.find(|event| event["event"] == kind)
.unwrap_or_else(|| panic!("missing event {kind}"))
}
#[test]
fn ping_over_socket_returns_version() {
let _lock = test_lock();
@ -278,6 +309,51 @@ fn ping_over_socket_returns_version() {
cleanup_spawned_herdr(child, base);
}
#[test]
fn server_reload_agent_manifests_reports_runtime_override() {
let _lock = test_lock();
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let child = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let override_dir = config_home.join("herdr-dev").join("agent-detection");
fs::create_dir_all(&override_dir).unwrap();
let override_path = override_dir.join("codex.toml");
fs::write(
&override_path,
r#"
id = "codex"
[[rules]]
id = "reload_marker"
state = "blocked"
contains = ["server-reload-marker"]
"#,
)
.unwrap();
let response = send_request(
&socket_path,
r#"{"id":"reload_manifests","method":"server.reload_agent_manifests","params":{}}"#,
);
assert_eq!(response["id"], "reload_manifests");
assert_eq!(response["result"]["type"], "agent_manifest_reload");
let manifests = response["result"]["manifests"].as_array().unwrap();
let codex = manifests
.iter()
.find(|manifest| manifest["agent"] == "codex")
.expect("codex manifest summary");
assert_eq!(codex["source_kind"], "local override");
assert_eq!(codex["source"], override_path.display().to_string());
assert!(codex.get("warning").is_none());
cleanup_spawned_herdr(child, base);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn workspace_list_and_create_round_trip() {
@ -1043,28 +1119,39 @@ fn events_subscribe_streams_workspace_tab_and_agent_events() {
.unwrap()
.to_string();
let workspace_created =
wait_for_event(&mut reader, "workspace_created", Duration::from_secs(2));
let initial_events = wait_for_events(
&mut reader,
&[
"workspace_created",
"workspace_focused",
"tab_created",
"tab_focused",
"pane_created",
"pane_focused",
],
Duration::from_secs(2),
);
let workspace_created = event_by_kind(&initial_events, "workspace_created");
assert_eq!(
workspace_created["data"]["workspace"]["workspace_id"],
workspace_id
);
let workspace_focused =
wait_for_event(&mut reader, "workspace_focused", Duration::from_secs(2));
let workspace_focused = event_by_kind(&initial_events, "workspace_focused");
assert_eq!(workspace_focused["data"]["workspace_id"], workspace_id);
let first_tab_id = format!("{workspace_id}:1");
let tab_created = wait_for_event(&mut reader, "tab_created", Duration::from_secs(2));
let tab_created = event_by_kind(&initial_events, "tab_created");
assert_eq!(tab_created["data"]["tab"]["tab_id"], first_tab_id);
let tab_focused = wait_for_event(&mut reader, "tab_focused", Duration::from_secs(2));
let tab_focused = event_by_kind(&initial_events, "tab_focused");
assert_eq!(tab_focused["data"]["tab_id"], first_tab_id);
let pane_created = wait_for_event(&mut reader, "pane_created", Duration::from_secs(2));
let pane_created = event_by_kind(&initial_events, "pane_created");
let pane_id = pane_created["data"]["pane"]["pane_id"]
.as_str()
.unwrap()
.to_string();
let pane_focused = wait_for_event(&mut reader, "pane_focused", Duration::from_secs(2));
let pane_focused = event_by_kind(&initial_events, "pane_focused");
assert_eq!(pane_focused["data"]["pane_id"], pane_id);
let send_pi = send_request(