diff --git a/AGENTS.md b/AGENTS.md index 7658a051..f04fbed5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 --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 --source detection --format text` and inspect matching with `herdr agent explain --json`. Update the bundled manifest in `src/detect/manifests/.toml`, copy that manifest to the local override path at `~/.config/herdr/agent-detection/.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. diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 8829c802..3060e328 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -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 `. - 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 `. - 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=`. (#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=`. (#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. diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index e7abe5c5..2ccc6154 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -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/.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 +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. diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index bd98c1d5..5fe28cd6 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -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 Read output: ```bash -herdr pane read [--source visible|recent|recent-unwrapped] [--lines N] +herdr pane read [--source visible|recent|recent-unwrapped|detection] [--lines N] herdr pane read --source visible --ansi herdr pane read --source recent-unwrapped --lines 120 ``` @@ -180,19 +182,23 @@ herdr pane report-metadata \ ```bash herdr agent list herdr agent get -herdr agent read [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi] +herdr agent read [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi] herdr agent send herdr agent rename |--clear herdr agent focus herdr agent wait --status [--timeout MS] herdr agent attach [--takeover] herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- +herdr agent explain [--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 diff --git a/docs/next/website/src/content/docs/concepts.mdx b/docs/next/website/src/content/docs/concepts.mdx index 356d73db..3c3d7849 100644 --- a/docs/next/website/src/content/docs/concepts.mdx +++ b/docs/next/website/src/content/docs/concepts.mdx @@ -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: diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index 47ab11ed..565a3c04 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -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 `. 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 `. 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 `. 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 `. 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 `. 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 `. 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 `. -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. diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 7b820539..a6b91461 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -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 diff --git a/justfile b/justfile index 5ef53e1e..26f6b8ed 100644 --- a/justfile +++ b/justfile @@ -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"; \ diff --git a/scripts/agent_detection_manifest_check.py b/scripts/agent_detection_manifest_check.py new file mode 100644 index 00000000..46dcdf69 --- /dev/null +++ b/scripts/agent_detection_manifest_check.py @@ -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()) diff --git a/scripts/capture_agent_screen.py b/scripts/capture_agent_screen.py new file mode 100755 index 00000000..4694c35d --- /dev/null +++ b/scripts/capture_agent_screen.py @@ -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 --source detection --format text"', + 'detection_ansi = "herdr pane read --source detection --format ansi"', + 'recent_text = "herdr pane read --source recent --lines --format text"', + 'recent_ansi = "herdr pane read --source recent --lines --format ansi"', + 'explain = "herdr agent explain --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()) diff --git a/scripts/test_agent_detection_manifest_check.py b/scripts/test_agent_detection_manifest_check.py new file mode 100644 index 00000000..3ab69b55 --- /dev/null +++ b/scripts/test_agent_detection_manifest_check.py @@ -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() diff --git a/src/agent_detection_policy.rs b/src/agent_detection_policy.rs deleted file mode 100644 index 7a2677a2..00000000 --- a/src/agent_detection_policy.rs +++ /dev/null @@ -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, - pub screen_detection: AgentDetection, - pub process_exited: bool, - pub startup_grace_active: bool, - pub pty_signal: Option, -} - -#[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, - }) - ); - } -} diff --git a/src/api/mod.rs b/src/api/mod.rs index c168313b..a9a8e25b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -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(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index 33040248..0eb2d8e2 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -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, + }, + AgentManifestStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + last_check_unix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + last_result: Option, + manifests: Vec, + }, ConfigReload { status: crate::config::ConfigReloadStatus, diagnostics: Vec, @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cached_remote_version: Option, + pub local_override_shadowing_remote: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_update_result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_update_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_last_checked_unix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warning: Option, +} + #[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, 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, #[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"}}"#; diff --git a/src/api/server.rs b/src/api/server.rs index 94e8c725..0d49d21d 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -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", diff --git a/src/app/actions.rs b/src/app/actions.rs index 249d0e64..b218e53c 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -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::>() + .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 { 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"]); diff --git a/src/app/agents.rs b/src/app/agents.rs index 6d463b28..ea828eae 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -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, diff --git a/src/app/api.rs b/src/app/api.rs index 76d9c537..ea4ea378 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -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::>()) + } 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 (!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() { diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index 4077b38c..f89be0ce 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -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(¶ms.target) { Ok(resolved) => resolved, diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index d824d26b..5afac850 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -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(), }, }; diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs index 66ec3226..0e7c0d81 100644 --- a/src/app/input/settings.rs +++ b/src/app/input/settings.rs @@ -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] diff --git a/src/app/mod.rs b/src/app/mod.rs index 35ab92b1..c0af6754 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -107,6 +107,7 @@ pub struct App { pub(crate) next_resize_poll: Instant, pub(crate) next_animation_tick: Option, pub(crate) next_auto_update_check: Option, + pub(crate) next_agent_manifest_update_check: Option, pub(crate) agent_metadata_deadline: Option, pub(crate) pending_agent_resume_deadline: Option, pub(crate) selection_autoscroll_deadline: Option, @@ -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; diff --git a/src/app/runtime.rs b/src/app/runtime.rs index ca780360..cb2e5712 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -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, diff --git a/src/app/state.rs b/src/app/state.rs index 01f3d9c8..f37ccb22 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1377,6 +1377,10 @@ pub struct AppState { pub settings: SettingsState, /// Cached integration recommendations for onboarding/settings UI. pub integration_recommendations: Vec, + /// Cached detection manifest source/version summaries for runtime/API status. + pub agent_manifest_summaries: Vec, + /// 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, /// 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(), diff --git a/src/cli.rs b/src/cli.rs index 88408fc8..276462b7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -737,6 +737,7 @@ pub(super) fn parse_read_source(value: &str) -> std::io::Result { "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}" ))), diff --git a/src/cli/agent.rs b/src/cli/agent.rs index 5005eda1..24f1880d 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -19,6 +19,7 @@ pub(super) fn run_agent_command(args: &[String]) -> std::io::Result { "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 { } } +fn agent_explain(args: &[String]) -> std::io::Result { + 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 [--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 [--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 [--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 { let Some(name) = args.first() else { eprintln!("usage: herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- "); @@ -422,6 +636,8 @@ fn print_agent_help() { eprintln!(" herdr agent wait --status [--timeout MS]"); eprintln!(" herdr agent attach [--takeover]"); eprintln!(" herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- "); + eprintln!(" herdr agent explain [--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" diff --git a/src/cli/server.rs b/src/cli/server.rs index 0102b2d4..f6cf7e54 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -10,6 +10,8 @@ pub(super) fn run_server_command(args: &[String]) -> std::io::Result "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 { })?) } +fn server_agent_manifests(args: &[String]) -> std::io::Result { + 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 { + 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 { 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)] diff --git a/src/detect/agents/amp.rs b/src/detect/agents/amp.rs deleted file mode 100644 index b1fcba4d..00000000 --- a/src/detect/agents/amp.rs +++ /dev/null @@ -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) -} diff --git a/src/detect/agents/antigravity.rs b/src/detect/agents/antigravity.rs deleted file mode 100644 index 9790c5a4..00000000 --- a/src/detect/agents/antigravity.rs +++ /dev/null @@ -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 { - 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") -} diff --git a/src/detect/agents/claude_code.rs b/src/detect/agents/claude_code.rs deleted file mode 100644 index b159fede..00000000 --- a/src/detect/agents/claude_code.rs +++ /dev/null @@ -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::().is_ok()) -} - -fn prompt_box_body_lines(content: &str) -> Option> { - 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::().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::() 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 { - 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::>() - .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)); - } -} diff --git a/src/detect/agents/cline.rs b/src/detect/agents/cline.rs deleted file mode 100644 index c0ce6f6e..00000000 --- a/src/detect/agents/cline.rs +++ /dev/null @@ -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")) -} diff --git a/src/detect/agents/codex.rs b/src/detect/agents/codex.rs deleted file mode 100644 index 93d2998c..00000000 --- a/src/detect/agents/codex.rs +++ /dev/null @@ -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::>() - .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") -} diff --git a/src/detect/agents/cursor.rs b/src/detect/agents/cursor.rs deleted file mode 100644 index f0f00b9b..00000000 --- a/src/detect/agents/cursor.rs +++ /dev/null @@ -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") -} diff --git a/src/detect/agents/droid.rs b/src/detect/agents/droid.rs deleted file mode 100644 index 78dc811e..00000000 --- a/src/detect/agents/droid.rs +++ /dev/null @@ -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) -} diff --git a/src/detect/agents/gemini.rs b/src/detect/agents/gemini.rs deleted file mode 100644 index 86cf3bca..00000000 --- a/src/detect/agents/gemini.rs +++ /dev/null @@ -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")) - }) -} diff --git a/src/detect/agents/github_copilot.rs b/src/detect/agents/github_copilot.rs deleted file mode 100644 index 0eece95b..00000000 --- a/src/detect/agents/github_copilot.rs +++ /dev/null @@ -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")) -} diff --git a/src/detect/agents/grok.rs b/src/detect/agents/grok.rs deleted file mode 100644 index 8a5834a2..00000000 --- a/src/detect/agents/grok.rs +++ /dev/null @@ -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") -} diff --git a/src/detect/agents/hermes.rs b/src/detect/agents/hermes.rs deleted file mode 100644 index 474dda5c..00000000 --- a/src/detect/agents/hermes.rs +++ /dev/null @@ -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 -} diff --git a/src/detect/agents/kilo.rs b/src/detect/agents/kilo.rs deleted file mode 100644 index 46a5601a..00000000 --- a/src/detect/agents/kilo.rs +++ /dev/null @@ -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) -} diff --git a/src/detect/agents/kimi.rs b/src/detect/agents/kimi.rs deleted file mode 100644 index 9d3821ed..00000000 --- a/src/detect/agents/kimi.rs +++ /dev/null @@ -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') -} diff --git a/src/detect/agents/kiro.rs b/src/detect/agents/kiro.rs deleted file mode 100644 index 889f5315..00000000 --- a/src/detect/agents/kiro.rs +++ /dev/null @@ -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) - }) -} diff --git a/src/detect/agents/mod.rs b/src/detect/agents/mod.rs deleted file mode 100644 index 72aaf674..00000000 --- a/src/detect/agents/mod.rs +++ /dev/null @@ -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, - } -} diff --git a/src/detect/agents/opencode.rs b/src/detect/agents/opencode.rs deleted file mode 100644 index ab8a424f..00000000 --- a/src/detect/agents/opencode.rs +++ /dev/null @@ -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 -} diff --git a/src/detect/agents/pi.rs b/src/detect/agents/pi.rs deleted file mode 100644 index 272231f5..00000000 --- a/src/detect/agents/pi.rs +++ /dev/null @@ -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 -} diff --git a/src/detect/agents/qodercli.rs b/src/detect/agents/qodercli.rs deleted file mode 100644 index d2802464..00000000 --- a/src/detect/agents/qodercli.rs +++ /dev/null @@ -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+2800–U+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") -} diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs new file mode 100644 index 00000000..6f660367 --- /dev/null +++ b/src/detect/manifest.rs @@ -0,0 +1,1411 @@ +use std::{ + path::{Path, PathBuf}, + sync::{Mutex, OnceLock, RwLock}, +}; + +use regex::Regex; +use serde::Deserialize; + +use super::{ + agent_label, manifest_update::ManifestVersion, parse_agent_label, Agent, AgentDetection, + AgentState, +}; + +pub const DEFAULT_KNOWN_AGENT_IDLE_FALLBACK: &str = "default_known_agent_idle_fallback"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectionExplain { + pub agent: Option, + pub state: AgentState, + pub source: Option, + pub matched_rule: Option, + pub screen_detection_skipped: bool, + pub visible_idle: bool, + pub visible_blocker: bool, + pub visible_working: bool, + pub skip_state_update: bool, + pub skipped_update_reason: Option, + pub fallback_reason: Option, + pub evaluated_rules: Vec, + pub warning: Option, + pub manifest_version: Option, + pub cached_remote_version: Option, + pub local_override_shadowing_remote: bool, + pub remote_update_status: Option, + pub remote_update_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ManifestSource { + Bundled, + Remote { path: PathBuf, version: String }, + Override(PathBuf), +} + +impl ManifestSource { + pub fn label(&self) -> String { + match self { + Self::Bundled => "bundled".to_string(), + Self::Remote { path, .. } => format!("remote:{}", path.display()), + Self::Override(path) => path.display().to_string(), + } + } + + pub fn kind(&self) -> &'static str { + match self { + Self::Bundled => "bundled", + Self::Remote { .. } => "remote", + Self::Override(_) => "local override", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AgentManifestSummary { + pub(crate) agent: Agent, + pub(crate) active_source: ManifestSource, + pub(crate) active_version: Option, + pub(crate) cached_remote_version: Option, + pub(crate) local_override_shadowing_remote: bool, + pub(crate) warning: Option, +} + +pub(crate) fn manifest_summaries() -> Vec { + let lock = manifest_cache(); + let guard = match lock.read() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + manifest_summaries_from_cache(&guard) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MatchedRule { + pub id: String, + pub priority: i32, + pub region: String, + pub state: AgentState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EvaluatedRule { + pub id: String, + pub priority: i32, + pub region: String, + pub evidence: RuleEvidence, + pub state: AgentState, + pub matched: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuleEvidence { + pub contains: Vec, + pub regex: Vec, + pub line_regex: Vec, + pub all_count: usize, + pub any_count: usize, + pub not_count: usize, + pub region_bytes: usize, + pub region_preview: String, +} + +#[derive(Debug, Clone)] +struct LoadedManifest { + manifest: AgentManifest, + compiled_rules: Vec, + source: ManifestSource, + warning: Option, + cached_remote_version: Option, + local_override_shadowing_remote: bool, +} + +#[derive(Debug, Clone)] +struct ManifestCache { + manifests: Vec<(Agent, Option)>, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub(crate) struct AgentManifest { + id: String, + version: Option, + min_engine_version: Option, + #[serde(rename = "updated_at")] + _updated_at: Option, + #[serde(default)] + aliases: Vec, + #[serde(default)] + rules: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +struct ManifestRule { + id: String, + state: Option, + #[serde(default)] + priority: i32, + #[serde(default = "default_region")] + region: String, + #[serde(default)] + visible_idle: bool, + #[serde(default)] + visible_blocker: bool, + #[serde(default)] + visible_working: bool, + #[serde(default)] + skip_state_update: bool, + #[serde(default)] + all: Vec, + #[serde(default)] + any: Vec, + #[serde(default, rename = "not")] + not_gate: Vec, + #[serde(default)] + contains: Vec, + #[serde(default)] + regex: Vec, + #[serde(default)] + line_regex: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +struct ManifestGate { + #[serde(default)] + all: Vec, + #[serde(default)] + any: Vec, + #[serde(default, rename = "not")] + not_gate: Vec, + #[serde(default)] + contains: Vec, + #[serde(default)] + regex: Vec, + #[serde(default)] + line_regex: Vec, +} + +#[derive(Debug, Clone)] +struct CompiledRule { + gate: CompiledGate, +} + +#[derive(Debug, Clone)] +struct CompiledGate { + all: Vec, + any: Vec, + not_gate: Vec, + contains: Vec, + regex: Vec, + line_regex: Vec, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum ManifestState { + Idle, + Working, + Blocked, + Unknown, +} + +impl From for AgentState { + fn from(value: ManifestState) -> Self { + match value { + ManifestState::Idle => AgentState::Idle, + ManifestState::Working => AgentState::Working, + ManifestState::Blocked => AgentState::Blocked, + ManifestState::Unknown => AgentState::Unknown, + } + } +} + +fn default_region() -> String { + "whole_recent".to_string() +} + +const BUNDLED_MANIFESTS: &[(&str, &str)] = &[ + ("amp", include_str!("manifests/amp.toml")), + ("agy", include_str!("manifests/antigravity.toml")), + ("claude", include_str!("manifests/claude.toml")), + ("cline", include_str!("manifests/cline.toml")), + ("codex", include_str!("manifests/codex.toml")), + ("cursor", include_str!("manifests/cursor.toml")), + ("droid", include_str!("manifests/droid.toml")), + ("gemini", include_str!("manifests/gemini.toml")), + ("grok", include_str!("manifests/grok.toml")), + ("hermes", include_str!("manifests/hermes.toml")), + ("kilo", include_str!("manifests/kilo.toml")), + ("kimi", include_str!("manifests/kimi.toml")), + ("kiro", include_str!("manifests/kiro.toml")), + ("opencode", include_str!("manifests/opencode.toml")), + ("pi", include_str!("manifests/pi.toml")), + ("qodercli", include_str!("manifests/qodercli.toml")), + ("copilot", include_str!("manifests/github-copilot.toml")), +]; + +static MANIFEST_CACHE: OnceLock> = OnceLock::new(); +static MANIFEST_RELOAD_LOCK: OnceLock> = OnceLock::new(); + +const MAX_RULES_PER_MANIFEST: usize = 128; +const MAX_GATE_DEPTH: usize = 8; +const MAX_TOTAL_GATES: usize = 512; +const MAX_MATCHERS_PER_GATE: usize = 32; +const MAX_TOTAL_MATCHERS: usize = 1024; +const MAX_MATCHER_CHARS: usize = 512; + +pub(crate) fn reload_manifests() -> Vec { + let _reload_guard = MANIFEST_RELOAD_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let cache = build_manifest_cache(); + let summaries = manifest_summaries_from_cache(&cache); + let lock = MANIFEST_CACHE.get_or_init(|| RwLock::new(cache.clone())); + match lock.write() { + Ok(mut guard) => *guard = cache, + Err(poisoned) => *poisoned.into_inner() = cache, + } + summaries +} + +fn manifest_cache() -> &'static RwLock { + MANIFEST_CACHE.get_or_init(|| RwLock::new(build_manifest_cache())) +} + +fn build_manifest_cache() -> ManifestCache { + ManifestCache { + manifests: Agent::ALL + .into_iter() + .map(|agent| (agent, load_manifest_uncached(agent))) + .collect(), + } +} + +fn manifest_summaries_from_cache(cache: &ManifestCache) -> Vec { + cache + .manifests + .iter() + .filter_map(|(agent, loaded)| { + loaded + .clone() + .map(|loaded| manifest_summary_from_loaded(*agent, loaded)) + }) + .collect() +} + +fn manifest_summary_from_loaded(agent: Agent, loaded: LoadedManifest) -> AgentManifestSummary { + AgentManifestSummary { + agent, + active_version: loaded.manifest.version.as_ref().map(ToString::to_string), + active_source: loaded.source, + cached_remote_version: loaded.cached_remote_version, + local_override_shadowing_remote: loaded.local_override_shadowing_remote, + warning: loaded.warning, + } +} + +pub fn detect(agent: Agent, screen_content: &str) -> AgentDetection { + let Some(loaded) = load_manifest(agent) else { + return fallback_explain(Some(agent), None, false).into_detection(); + }; + evaluate_loaded_manifest(agent, screen_content, loaded, false).into_detection() +} + +pub fn explain(agent: Agent, screen_content: &str) -> DetectionExplain { + let Some(loaded) = load_manifest(agent) else { + return fallback_explain(Some(agent), None, true); + }; + evaluate_loaded_manifest(agent, screen_content, loaded, true) +} + +pub fn explain_for_label(agent_label: &str, screen_content: &str) -> DetectionExplain { + let Some(agent) = parse_agent_label(agent_label) else { + return DetectionExplain { + agent: Some(agent_label.to_string()), + state: AgentState::Unknown, + source: None, + matched_rule: None, + screen_detection_skipped: false, + visible_idle: false, + visible_blocker: false, + visible_working: false, + skip_state_update: false, + skipped_update_reason: None, + fallback_reason: Some("unknown_agent".to_string()), + evaluated_rules: Vec::new(), + warning: None, + manifest_version: None, + cached_remote_version: None, + local_override_shadowing_remote: false, + remote_update_status: None, + remote_update_error: None, + }; + }; + explain(agent, screen_content) +} + +pub fn should_skip_state_update(agent: Agent, screen_content: &str) -> bool { + let Some(loaded) = load_manifest(agent) else { + return false; + }; + evaluate_loaded_manifest(agent, screen_content, loaded, false).skip_state_update +} + +impl DetectionExplain { + fn into_detection(self) -> AgentDetection { + AgentDetection { + state: self.state, + skip_state_update: self.skip_state_update, + visible_idle: self.visible_idle, + visible_blocker: self.visible_blocker, + visible_working: self.visible_working, + } + } +} + +fn evaluate_loaded_manifest( + agent: Agent, + screen_content: &str, + loaded: LoadedManifest, + include_update_status: bool, +) -> DetectionExplain { + let mut matched: Option<(&ManifestRule, String)> = None; + let mut evaluated_rules = Vec::new(); + + for (rule, compiled_rule) in loaded.manifest.rules.iter().zip(&loaded.compiled_rules) { + let region_text = region(screen_content, &rule.region); + let matched_rule = compiled_rule_matches(compiled_rule, region_text); + evaluated_rules.push(EvaluatedRule { + id: rule.id.clone(), + priority: rule.priority, + region: rule.region.clone(), + evidence: rule_evidence(rule, region_text), + state: rule + .state + .map(AgentState::from) + .unwrap_or(AgentState::Unknown), + matched: matched_rule, + }); + + if !matched_rule { + continue; + } + + match matched { + Some((previous, _)) if previous.priority >= rule.priority => {} + _ => matched = Some((rule, rule.region.clone())), + } + } + + let Some((rule, region_name)) = matched else { + return fallback_explain( + Some(agent), + Some((loaded, evaluated_rules)), + include_update_status, + ); + }; + + let state = rule + .state + .map(AgentState::from) + .unwrap_or(AgentState::Unknown); + let skipped_update_reason = rule + .skip_state_update + .then(|| format!("matched_rule:{}", rule.id)); + + let remote_update_status = include_update_status + .then(|| remote_update_status(agent)) + .flatten(); + + DetectionExplain { + agent: Some(agent_label(agent).to_string()), + state, + source: Some(loaded.source), + matched_rule: Some(MatchedRule { + id: rule.id.clone(), + priority: rule.priority, + region: region_name, + state, + }), + screen_detection_skipped: false, + visible_idle: rule.visible_idle && state == AgentState::Idle, + visible_blocker: rule.visible_blocker && state == AgentState::Blocked, + visible_working: rule.visible_working && state == AgentState::Working, + skip_state_update: rule.skip_state_update, + skipped_update_reason, + fallback_reason: None, + evaluated_rules, + warning: loaded.warning, + manifest_version: loaded.manifest.version.as_ref().map(ToString::to_string), + cached_remote_version: loaded.cached_remote_version, + local_override_shadowing_remote: loaded.local_override_shadowing_remote, + remote_update_status: remote_update_status + .as_ref() + .map(|status| status.last_result.clone()), + remote_update_error: remote_update_status.and_then(|status| status.last_error), + } +} + +fn fallback_explain( + agent: Option, + context: Option<(LoadedManifest, Vec)>, + include_update_status: bool, +) -> DetectionExplain { + let ( + source, + evaluated_rules, + warning, + manifest_version, + cached_remote_version, + local_override_shadowing_remote, + ) = context + .map(|(loaded, evaluated)| { + ( + Some(loaded.source), + evaluated, + loaded.warning, + loaded.manifest.version.as_ref().map(ToString::to_string), + loaded.cached_remote_version, + loaded.local_override_shadowing_remote, + ) + }) + .unwrap_or((None, Vec::new(), None, None, None, false)); + let known_agent = agent.is_some(); + let remote_update_status = include_update_status + .then(|| agent.and_then(remote_update_status)) + .flatten(); + + DetectionExplain { + agent: agent.map(|agent| agent_label(agent).to_string()), + state: if known_agent { + AgentState::Idle + } else { + AgentState::Unknown + }, + source, + matched_rule: None, + screen_detection_skipped: false, + visible_idle: false, + visible_blocker: false, + visible_working: false, + skip_state_update: false, + skipped_update_reason: None, + fallback_reason: known_agent.then(|| DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()), + evaluated_rules, + warning, + manifest_version, + cached_remote_version, + local_override_shadowing_remote, + remote_update_status: remote_update_status + .as_ref() + .map(|status| status.last_result.clone()), + remote_update_error: remote_update_status.and_then(|status| status.last_error), + } +} + +fn load_manifest(agent: Agent) -> Option { + let lock = manifest_cache(); + let guard = match lock.read() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + guard + .manifests + .iter() + .find(|(cached_agent, _)| *cached_agent == agent) + .and_then(|(_, loaded)| loaded.clone()) +} + +fn load_manifest_uncached(agent: Agent) -> Option { + let bundled = bundled_manifest(agent)?; + let mut remote = read_remote_manifest(agent, &bundled); + let cached_remote_version = remote.as_ref().and_then(|loaded| match &loaded.source { + _ if loaded.cached_remote_version.is_some() => loaded.cached_remote_version.clone(), + ManifestSource::Remote { version, .. } => Some(version.clone()), + _ => None, + }); + let Some(path) = override_path(agent) else { + if let Some(loaded) = remote.as_mut() { + loaded.cached_remote_version = cached_remote_version.clone(); + } + return Some(remote.unwrap_or_else(|| { + bundled_loaded_manifest(agent, bundled, None, cached_remote_version, false) + })); + }; + let local_override_shadowing_remote = path.exists() && cached_remote_version.is_some(); + if let Some(loaded) = remote.as_mut() { + loaded.cached_remote_version = cached_remote_version.clone(); + loaded.local_override_shadowing_remote = local_override_shadowing_remote; + } + + if !path.exists() { + return Some(remote.unwrap_or_else(|| { + bundled_loaded_manifest( + agent, + bundled, + None, + cached_remote_version, + local_override_shadowing_remote, + ) + })); + } + + match read_override_manifest(&path) { + Ok(manifest) if manifest_matches_agent(&manifest, agent) => { + match loaded_manifest( + manifest, + ManifestSource::Override(path.clone()), + None, + cached_remote_version.clone(), + local_override_shadowing_remote, + ) { + Ok(loaded) => Some(loaded), + Err(err) => { + let mut loaded = remote.unwrap_or_else(|| { + bundled_loaded_manifest( + agent, + bundled, + None, + cached_remote_version, + local_override_shadowing_remote, + ) + }); + loaded.warning = Some(format!( + "ignored override {} because it could not be compiled: {err}", + path.display() + )); + Some(loaded) + } + } + } + Ok(manifest) => { + let mut loaded = remote.unwrap_or_else(|| { + bundled_loaded_manifest( + agent, + bundled, + None, + cached_remote_version, + local_override_shadowing_remote, + ) + }); + loaded.warning = Some(format!( + "ignored override {} because manifest id {} does not match {}", + path.display(), + manifest.id, + agent_label(agent) + )); + Some(loaded) + } + Err(err) => { + let mut loaded = remote.unwrap_or_else(|| { + bundled_loaded_manifest( + agent, + bundled, + None, + cached_remote_version, + local_override_shadowing_remote, + ) + }); + loaded.warning = Some(format!( + "ignored override {} because it could not be loaded: {err}", + path.display() + )); + Some(loaded) + } + } +} + +fn loaded_manifest( + manifest: AgentManifest, + source: ManifestSource, + warning: Option, + cached_remote_version: Option, + local_override_shadowing_remote: bool, +) -> Result { + let compiled_rules = compile_manifest(&manifest)?; + Ok(LoadedManifest { + manifest, + compiled_rules, + source, + warning, + cached_remote_version, + local_override_shadowing_remote, + }) +} + +fn bundled_loaded_manifest( + agent: Agent, + manifest: AgentManifest, + warning: Option, + cached_remote_version: Option, + local_override_shadowing_remote: bool, +) -> LoadedManifest { + loaded_manifest( + manifest, + ManifestSource::Bundled, + warning, + cached_remote_version, + local_override_shadowing_remote, + ) + .unwrap_or_else(|err| { + panic!( + "bundled {} manifest could not be compiled: {err}", + agent_label(agent) + ) + }) +} + +fn bundled_manifest(agent: Agent) -> Option { + let id = agent_label(agent); + BUNDLED_MANIFESTS + .iter() + .find(|(manifest_id, _)| *manifest_id == id) + .map(|(_, content)| { + parse_manifest(content) + .unwrap_or_else(|err| panic!("bundled {id} manifest is invalid: {err}")) + }) +} + +fn read_override_manifest(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(|err| err.to_string())?; + parse_manifest(&content) +} + +fn read_remote_manifest(agent: Agent, bundled: &AgentManifest) -> Option { + let path = super::manifest_update::remote_manifest_path(agent); + if !path.exists() { + return None; + } + match std::fs::read_to_string(&path) + .map_err(|err| err.to_string()) + .and_then(|content| { + parse_remote_manifest_for_agent(agent, &content).map(|parsed| parsed.manifest) + }) { + Ok(manifest) => { + let version = manifest + .version + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "unknown".to_string()); + if let (Some(remote_version), Some(bundled_version)) = + (manifest.version.as_ref(), bundled.version.as_ref()) + { + if remote_version < bundled_version { + return Some(bundled_loaded_manifest( + agent, + bundled.clone(), + Some(format!( + "ignored remote manifest {} because cached version {remote_version} is older than bundled {bundled_version}", + path.display() + )), + Some(remote_version.to_string()), + false, + )); + } + } + match loaded_manifest( + manifest, + ManifestSource::Remote { + path: path.clone(), + version, + }, + None, + None, + false, + ) { + Ok(loaded) => Some(loaded), + Err(err) => Some(bundled_loaded_manifest( + agent, + bundled.clone(), + Some(format!( + "ignored remote manifest {} because it could not be compiled: {err}", + path.display() + )), + None, + false, + )), + } + } + Err(err) => Some(bundled_loaded_manifest( + agent, + bundled.clone(), + Some(format!( + "ignored remote manifest {} because it could not be loaded: {err}", + path.display() + )), + None, + false, + )), + } +} + +pub fn agent_state_label(state: AgentState) -> &'static str { + match state { + AgentState::Idle => "idle", + AgentState::Working => "working", + AgentState::Blocked => "blocked", + AgentState::Unknown => "unknown", + } +} + +pub fn explain_to_json_value(explain: &DetectionExplain) -> serde_json::Value { + let matched_rule = explain.matched_rule.as_ref().map(|rule| { + serde_json::json!({ + "id": rule.id, + "priority": rule.priority, + "region": rule.region, + "state": agent_state_label(rule.state), + }) + }); + let evaluated_rules: Vec<_> = explain + .evaluated_rules + .iter() + .map(|rule| { + serde_json::json!({ + "id": rule.id, + "priority": rule.priority, + "region": rule.region, + "state": agent_state_label(rule.state), + "matched": rule.matched, + "evidence": { + "contains": &rule.evidence.contains, + "regex": &rule.evidence.regex, + "line_regex": &rule.evidence.line_regex, + "all_count": rule.evidence.all_count, + "any_count": rule.evidence.any_count, + "not_count": rule.evidence.not_count, + "region_bytes": rule.evidence.region_bytes, + "region_preview": &rule.evidence.region_preview, + }, + }) + }) + .collect(); + + serde_json::json!({ + "agent": explain.agent, + "state": agent_state_label(explain.state), + "manifest_source": explain.source.as_ref().map(|source| source.label()), + "manifest_version": &explain.manifest_version, + "cached_remote_version": &explain.cached_remote_version, + "local_override_shadowing_remote": explain.local_override_shadowing_remote, + "remote_update_status": &explain.remote_update_status, + "remote_update_error": &explain.remote_update_error, + "matched_rule": matched_rule, + "visible_idle": explain.visible_idle, + "visible_blocker": explain.visible_blocker, + "visible_working": explain.visible_working, + "screen_detection_skipped": explain.screen_detection_skipped, + "skip_state_update": explain.skip_state_update, + "skipped_update_reason": explain.skipped_update_reason, + "fallback_reason": explain.fallback_reason, + "warning": explain.warning, + "evaluated_rules": evaluated_rules, + }) +} + +pub(crate) struct ParsedRemoteManifest { + pub(crate) manifest: AgentManifest, + pub(crate) version: ManifestVersion, +} + +pub(crate) fn parse_manifest(content: &str) -> Result { + let manifest = toml::from_str::(content).map_err(|err| err.to_string())?; + validate_manifest(&manifest)?; + Ok(manifest) +} + +pub(crate) fn parse_remote_manifest_for_agent( + agent: Agent, + content: &str, +) -> Result { + let manifest = parse_manifest(content)?; + if !manifest_matches_agent(&manifest, agent) { + return Err(format!( + "manifest id {} does not match {}", + manifest.id, + agent_label(agent) + )); + } + let version = manifest + .version + .clone() + .ok_or("remote manifest must include version")?; + let min_engine_version = manifest + .min_engine_version + .ok_or("remote manifest must include min_engine_version")?; + if min_engine_version > super::manifest_update::MANIFEST_ENGINE_VERSION { + return Err(format!( + "manifest requires engine {min_engine_version}, current engine is {}", + super::manifest_update::MANIFEST_ENGINE_VERSION + )); + } + Ok(ParsedRemoteManifest { manifest, version }) +} + +fn validate_manifest(manifest: &AgentManifest) -> Result<(), String> { + if manifest.rules.is_empty() { + return Err("manifest must contain at least one rule".to_string()); + } + if manifest.rules.len() > MAX_RULES_PER_MANIFEST { + return Err(format!( + "manifest contains {} rules, max is {MAX_RULES_PER_MANIFEST}", + manifest.rules.len() + )); + } + + let mut complexity = ManifestComplexity::default(); + for rule in &manifest.rules { + if rule.id.trim().is_empty() { + return Err("manifest rule id must not be empty".to_string()); + } + if rule.skip_state_update { + if rule.state != Some(ManifestState::Unknown) { + return Err(format!( + "rule {} uses skip_state_update without state = \"unknown\"", + rule.id + )); + } + if rule.visible_idle || rule.visible_blocker || rule.visible_working { + return Err(format!( + "rule {} uses skip_state_update with visible state evidence", + rule.id + )); + } + } + validate_region_name(&rule.region) + .map_err(|err| format!("rule {} uses invalid region: {err}", rule.id))?; + validate_rule_gate(rule, &mut complexity) + .map_err(|err| format!("rule {} has invalid matcher gates: {err}", rule.id))?; + } + + Ok(()) +} + +#[derive(Default)] +struct ManifestComplexity { + total_gates: usize, + total_matchers: usize, +} + +fn validate_rule_gate( + rule: &ManifestRule, + complexity: &mut ManifestComplexity, +) -> Result<(), String> { + validate_gate(&manifest_gate_from_rule(rule), "rule", 0, complexity) +} + +fn validate_gate( + gate: &ManifestGate, + context: &str, + depth: usize, + complexity: &mut ManifestComplexity, +) -> Result<(), String> { + if depth > MAX_GATE_DEPTH { + return Err(format!("{context} exceeds max gate depth {MAX_GATE_DEPTH}")); + } + complexity.total_gates += 1; + if complexity.total_gates > MAX_TOTAL_GATES { + return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}")); + } + validate_matcher_limits(gate, context, complexity)?; + if !gate_has_positive_matcher(gate) { + return Err(format!("{context} must contain a positive matcher")); + } + validate_regex_patterns(&gate.regex, context, "regex")?; + validate_regex_patterns(&gate.line_regex, context, "line_regex")?; + for nested in &gate.all { + validate_gate(nested, "all gate", depth + 1, complexity)?; + } + for nested in &gate.any { + validate_gate(nested, "any gate", depth + 1, complexity)?; + } + for nested in &gate.not_gate { + if !gate_has_any_matcher(nested) { + return Err(format!("{context} contains an empty not gate")); + } + validate_not_gate(nested, depth + 1, complexity)?; + } + Ok(()) +} + +fn validate_not_gate( + gate: &ManifestGate, + depth: usize, + complexity: &mut ManifestComplexity, +) -> Result<(), String> { + if depth > MAX_GATE_DEPTH { + return Err(format!("not gate exceeds max gate depth {MAX_GATE_DEPTH}")); + } + complexity.total_gates += 1; + if complexity.total_gates > MAX_TOTAL_GATES { + return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}")); + } + validate_matcher_limits(gate, "not gate", complexity)?; + if !gate_has_any_matcher(gate) { + return Err("not gate must contain a matcher".to_string()); + } + validate_regex_patterns(&gate.regex, "not gate", "regex")?; + validate_regex_patterns(&gate.line_regex, "not gate", "line_regex")?; + for nested in &gate.all { + validate_gate(nested, "not all gate", depth + 1, complexity)?; + } + for nested in &gate.any { + validate_gate(nested, "not any gate", depth + 1, complexity)?; + } + for nested in &gate.not_gate { + validate_not_gate(nested, depth + 1, complexity)?; + } + Ok(()) +} + +fn validate_matcher_limits( + gate: &ManifestGate, + context: &str, + complexity: &mut ManifestComplexity, +) -> Result<(), String> { + let matcher_count = gate.contains.len() + gate.regex.len() + gate.line_regex.len(); + if matcher_count > MAX_MATCHERS_PER_GATE { + return Err(format!( + "{context} has {matcher_count} direct matchers, max is {MAX_MATCHERS_PER_GATE}" + )); + } + complexity.total_matchers += matcher_count; + if complexity.total_matchers > MAX_TOTAL_MATCHERS { + return Err(format!( + "manifest exceeds max matcher count {MAX_TOTAL_MATCHERS}" + )); + } + for value in gate + .contains + .iter() + .chain(gate.regex.iter()) + .chain(gate.line_regex.iter()) + { + if value.chars().count() > MAX_MATCHER_CHARS { + return Err(format!( + "{context} matcher exceeds max length {MAX_MATCHER_CHARS}" + )); + } + } + Ok(()) +} + +fn validate_regex_patterns(patterns: &[String], context: &str, field: &str) -> Result<(), String> { + for pattern in patterns { + Regex::new(pattern).map_err(|err| { + format!("{context} contains invalid {field} pattern {pattern:?}: {err}") + })?; + } + Ok(()) +} + +fn gate_has_positive_matcher(gate: &ManifestGate) -> bool { + !gate.contains.is_empty() + || !gate.regex.is_empty() + || !gate.line_regex.is_empty() + || !gate.all.is_empty() + || !gate.any.is_empty() +} + +fn gate_has_any_matcher(gate: &ManifestGate) -> bool { + gate_has_positive_matcher(gate) || !gate.not_gate.is_empty() +} + +fn validate_region_name(spec: &str) -> Result<(), String> { + let trimmed = spec.trim(); + match trimmed { + "whole_recent" + | "after_last_prompt_marker" + | "before_current_prompt_marker" + | "whole_recent_without_current_prompt_marker" + | "current_prompt_block_marker" + | "after_current_prompt_block_marker" + | "prompt_box_body" + | "above_prompt_box" + | "last_non_empty_above_prompt_box" + | "after_last_horizontal_rule" => Ok(()), + _ if region_count(trimmed, "bottom_lines").is_some() + || region_count(trimmed, "bottom_non_empty_lines").is_some() => + { + Ok(()) + } + _ => Err(trimmed.to_string()), + } +} + +fn override_path(agent: Agent) -> Option { + Some( + crate::config::config_dir() + .join("agent-detection") + .join(format!("{}.toml", agent_label(agent))), + ) +} + +fn remote_update_status(agent: Agent) -> Option { + super::manifest_update::load_status().agent_status(agent) +} + +fn manifest_matches_agent(manifest: &AgentManifest, agent: Agent) -> bool { + let id = agent_label(agent); + manifest.id == id + || manifest.aliases.iter().any(|alias| alias == id) + || parse_agent_label(&manifest.id) == Some(agent) + || manifest + .aliases + .iter() + .any(|alias| parse_agent_label(alias) == Some(agent)) +} + +fn manifest_gate_from_rule(rule: &ManifestRule) -> ManifestGate { + ManifestGate { + all: rule.all.clone(), + any: rule.any.clone(), + not_gate: rule.not_gate.clone(), + contains: rule.contains.clone(), + regex: rule.regex.clone(), + line_regex: rule.line_regex.clone(), + } +} + +fn compile_manifest(manifest: &AgentManifest) -> Result, String> { + manifest + .rules + .iter() + .map(|rule| { + compile_gate(&manifest_gate_from_rule(rule)) + .map(|gate| CompiledRule { gate }) + .map_err(|err| format!("rule {} could not be compiled: {err}", rule.id)) + }) + .collect() +} + +fn compile_gate(gate: &ManifestGate) -> Result { + Ok(CompiledGate { + all: gate + .all + .iter() + .map(compile_gate) + .collect::>()?, + any: gate + .any + .iter() + .map(compile_gate) + .collect::>()?, + not_gate: gate + .not_gate + .iter() + .map(compile_gate) + .collect::>()?, + contains: gate + .contains + .iter() + .map(|needle| needle.to_lowercase()) + .collect(), + regex: gate + .regex + .iter() + .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string())) + .collect::>()?, + line_regex: gate + .line_regex + .iter() + .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string())) + .collect::>()?, + }) +} + +fn compiled_rule_matches(rule: &CompiledRule, text: &str) -> bool { + let lower_text = text.to_lowercase(); + compiled_gate_matches(&rule.gate, text, &lower_text) +} + +fn rule_evidence(rule: &ManifestRule, region_text: &str) -> RuleEvidence { + RuleEvidence { + contains: rule.contains.clone(), + regex: rule.regex.clone(), + line_regex: rule.line_regex.clone(), + all_count: rule.all.len(), + any_count: rule.any.len(), + not_count: rule.not_gate.len(), + region_bytes: region_text.len(), + region_preview: bounded_preview(region_text), + } +} + +fn bounded_preview(text: &str) -> String { + const MAX_CHARS: usize = 240; + let mut preview: String = text.chars().take(MAX_CHARS).collect(); + if text.chars().count() > MAX_CHARS { + preview.push_str("..."); + } + preview +} + +fn compiled_gate_matches(gate: &CompiledGate, text: &str, lower_text: &str) -> bool { + if !gate + .contains + .iter() + .all(|needle| lower_text.contains(needle)) + { + return false; + } + + if !gate.regex.iter().all(|regex| regex.is_match(text)) { + return false; + } + + if !gate + .line_regex + .iter() + .all(|regex| text.lines().any(|line| regex.is_match(line))) + { + return false; + } + + if !gate + .all + .iter() + .all(|nested| compiled_gate_matches(nested, text, lower_text)) + { + return false; + } + + if !gate.any.is_empty() + && !gate + .any + .iter() + .any(|nested| compiled_gate_matches(nested, text, lower_text)) + { + return false; + } + + if gate + .not_gate + .iter() + .any(|nested| compiled_gate_matches(nested, text, lower_text)) + { + return false; + } + + true +} + +fn region<'a>(content: &'a str, spec: &str) -> &'a str { + let trimmed = spec.trim(); + match trimmed { + "whole_recent" => content, + "after_last_prompt_marker" => after_last_prompt_marker(content), + "before_current_prompt_marker" => before_current_prompt_marker(content), + "whole_recent_without_current_prompt_marker" => { + whole_recent_without_current_prompt_marker(content) + } + "current_prompt_block_marker" => current_prompt_block_marker(content).unwrap_or(""), + "after_current_prompt_block_marker" => { + after_current_prompt_block_marker(content).unwrap_or("") + } + "prompt_box_body" => prompt_box_body(content).unwrap_or(""), + "above_prompt_box" => above_prompt_box(content), + "last_non_empty_above_prompt_box" => last_non_empty_line(above_prompt_box(content)), + "after_last_horizontal_rule" => after_last_horizontal_rule(content), + _ => { + if let Some(count) = region_count(trimmed, "bottom_lines") { + return bottom_lines(content, count); + } + if let Some(count) = region_count(trimmed, "bottom_non_empty_lines") { + return bottom_non_empty_lines(content, count); + } + "" + } + } +} + +fn region_count(spec: &str, name: &str) -> Option { + spec.strip_prefix(name) + .and_then(|rest| rest.strip_prefix('(')) + .and_then(|rest| rest.strip_suffix(')')) + .and_then(|count| count.parse::().ok()) +} + +fn bottom_lines(content: &str, count: usize) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let start = lines.len().saturating_sub(count); + slice_from_line_index(content, &lines, start) +} + +fn bottom_non_empty_lines(content: &str, count: usize) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(start_index) = lines + .iter() + .enumerate() + .rev() + .filter(|(_, line)| !line.trim().is_empty()) + .take(count) + .last() + .map(|(index, _)| index) + else { + return ""; + }; + slice_from_line_index(content, &lines, start_index) +} + +fn after_last_prompt_marker(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(index) = lines.iter().rposition(|line| codex_prompt_line(line)) else { + return content; + }; + slice_from_line_index(content, &lines, index + 1) +} + +fn before_current_prompt_marker(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(index) = current_codex_prompt_index(&lines) else { + return content; + }; + let byte_offset = lines[..index] + .iter() + .map(|line| line.len() + 1) + .sum::(); + &content[..byte_offset.min(content.len())] +} + +fn whole_recent_without_current_prompt_marker(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + if current_codex_prompt_index(&lines).is_some() { + "" + } else { + content + } +} + +fn current_prompt_block_marker(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let prompt_index = current_codex_prompt_index(&lines)?; + lines[..prompt_index] + .iter() + .rev() + .find(|line| codex_block_marker_line(line)) + .copied() +} + +fn after_current_prompt_block_marker(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let prompt_index = current_codex_prompt_index(&lines)?; + let block_index = lines[..prompt_index] + .iter() + .rposition(|line| codex_block_marker_line(line))?; + Some(slice_from_line_index(content, &lines, block_index)) +} + +fn current_codex_prompt_index(lines: &[&str]) -> Option { + 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(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 prompt_box_body(content: &str) -> Option<&str> { + let lines: Vec<&str> = content.lines().collect(); + let top = prompt_box_top_border_index(&lines)?; + let start = line_start_offset(content, &lines, top + 1); + let end_index = lines[top + 1..] + .iter() + .position(|line| is_horizontal_rule(line)) + .map(|relative| top + 1 + relative) + .unwrap_or(lines.len()); + let end = line_start_offset(content, &lines, end_index); + Some(&content[start.min(content.len())..end.min(content.len())]) +} + +fn above_prompt_box(content: &str) -> &str { + let lines: Vec<&str> = content.lines().collect(); + let Some(top) = prompt_box_top_border_index(&lines) else { + return content; + }; + let end = line_start_offset(content, &lines, top); + &content[..end.min(content.len())] +} + +fn 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 last_non_empty_line(content: &str) -> &str { + content + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("") +} + +fn prompt_box_top_border_index(lines: &[&str]) -> Option { + let mut border_count = 0; + for index in (0..lines.len()).rev() { + if is_horizontal_rule(lines[index]) { + border_count += 1; + if border_count == 2 { + return Some(index); + } + } + } + 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(|&ch| ch == '─').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 slice_from_line_index<'a>(content: &'a str, lines: &[&str], index: usize) -> &'a str { + let byte_offset = line_start_offset(content, lines, index); + &content[byte_offset.min(content.len())..] +} + +fn line_start_offset(content: &str, lines: &[&str], index: usize) -> usize { + lines[..index.min(lines.len())] + .iter() + .map(|line| line.len() + 1) + .sum::() + .min(content.len()) +} + +#[cfg(test)] +mod tests; diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs new file mode 100644 index 00000000..e5b03c0b --- /dev/null +++ b/src/detect/manifest/tests.rs @@ -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(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::>() + .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" + ); +} diff --git a/src/detect/manifest_update.rs b/src/detect/manifest_update.rs new file mode 100644 index 00000000..6534b885 --- /dev/null +++ b/src/detect/manifest_update.rs @@ -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 { + 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::() + .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(deserializer: D) -> Result + 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(&self, serializer: S) -> Result + 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::().unwrap_or(0); + let right = right.parse::().unwrap_or(0); + match left.cmp(&right) { + Ordering::Equal => {} + ordering => return ordering, + } + } + (Some(left), None) => { + let left = left.parse::().unwrap_or(0); + if left == 0 { + continue; + } + return Ordering::Greater; + } + (None, Some(right)) => { + let right = right.parse::().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 { + 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, + pub(crate) last_result: Option, + #[serde(default)] + pub(crate) agents: BTreeMap, +} + +impl ManifestUpdateStatus { + pub(crate) fn agent_status(&self, agent: Agent) -> Option { + self.agents.get(agent_label(agent)).cloned() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct AgentRemoteStatus { + pub(crate) cached_version: Option, + pub(crate) attempted_version: Option, + pub(crate) last_checked_unix: Option, + pub(crate) last_result: String, + pub(crate) last_error: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestCatalog { + schema_version: u32, + #[serde(default)] + agents: Vec, +} + +#[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) { + 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, + pub(crate) status: ManifestUpdateStatus, +} + +pub(crate) fn check_and_update() -> Result { + check_and_update_from_url(&catalog_url()) +} + +fn check_and_update_from_url(url: &str) -> Result { + 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, String> { + let parsed = super::manifest::parse_remote_manifest_for_agent(agent, content)?; + if let Some(current) = cached_remote_version(agent) { + match parsed.version.cmp(¤t) { + 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, 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 { + 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 { + 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 { + 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 { + 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(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, ¤t, 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, ¤t, 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, ¤t, 1).unwrap(); + + let result = process_agent_manifest(Agent::Codex, ¤t, 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()); + } +} diff --git a/src/detect/manifests/amp.toml b/src/detect/manifests/amp.toml new file mode 100644 index 00000000..97ac99e0 --- /dev/null +++ b/src/detect/manifests/amp.toml @@ -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"] diff --git a/src/detect/manifests/antigravity.toml b/src/detect/manifests/antigravity.toml new file mode 100644 index 00000000..4e6e70b5 --- /dev/null +++ b/src/detect/manifests/antigravity.toml @@ -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'] diff --git a/src/detect/manifests/claude.toml b/src/detect/manifests/claude.toml new file mode 100644 index 00000000..84177af6 --- /dev/null +++ b/src/detect/manifests/claude.toml @@ -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*$'] }, +] diff --git a/src/detect/manifests/cline.toml b/src/detect/manifests/cline.toml new file mode 100644 index 00000000..b30f4417 --- /dev/null +++ b/src/detect/manifests/cline.toml @@ -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).+'] diff --git a/src/detect/manifests/codex.toml b/src/detect/manifests/codex.toml new file mode 100644 index 00000000..cd09e4b0 --- /dev/null +++ b/src/detect/manifests/codex.toml @@ -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 \()'] }, +] diff --git a/src/detect/manifests/cursor.toml b/src/detect/manifests/cursor.toml new file mode 100644 index 00000000..c97c03d8 --- /dev/null +++ b/src/detect/manifests/cursor.toml @@ -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'] diff --git a/src/detect/manifests/droid.toml b/src/detect/manifests/droid.toml new file mode 100644 index 00000000..c41d71b4 --- /dev/null +++ b/src/detect/manifests/droid.toml @@ -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"] diff --git a/src/detect/manifests/gemini.toml b/src/detect/manifests/gemini.toml new file mode 100644 index 00000000..9d7a28e1 --- /dev/null +++ b/src/detect/manifests/gemini.toml @@ -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"] diff --git a/src/detect/manifests/github-copilot.toml b/src/detect/manifests/github-copilot.toml new file mode 100644 index 00000000..dcd8e2ee --- /dev/null +++ b/src/detect/manifests/github-copilot.toml @@ -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"] }, +] diff --git a/src/detect/manifests/grok.toml b/src/detect/manifests/grok.toml new file mode 100644 index 00000000..f4b6537f --- /dev/null +++ b/src/detect/manifests/grok.toml @@ -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'] }, +] diff --git a/src/detect/manifests/hermes.toml b/src/detect/manifests/hermes.toml new file mode 100644 index 00000000..d45fe32a --- /dev/null +++ b/src/detect/manifests/hermes.toml @@ -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"] }, +] diff --git a/src/detect/manifests/kilo.toml b/src/detect/manifests/kilo.toml new file mode 100644 index 00000000..4ef004e1 --- /dev/null +++ b/src/detect/manifests/kilo.toml @@ -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"] diff --git a/src/detect/manifests/kimi.toml b/src/detect/manifests/kimi.toml new file mode 100644 index 00000000..b4d0100f --- /dev/null +++ b/src/detect/manifests/kimi.toml @@ -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 )'] diff --git a/src/detect/manifests/kiro.toml b/src/detect/manifests/kiro.toml new file mode 100644 index 00000000..9b0fb3d0 --- /dev/null +++ b/src/detect/manifests/kiro.toml @@ -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}'] diff --git a/src/detect/manifests/opencode.toml b/src/detect/manifests/opencode.toml new file mode 100644 index 00000000..52452383 --- /dev/null +++ b/src/detect/manifests/opencode.toml @@ -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,}'] diff --git a/src/detect/manifests/pi.toml b/src/detect/manifests/pi.toml new file mode 100644 index 00000000..a58d30e9 --- /dev/null +++ b/src/detect/manifests/pi.toml @@ -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..."] diff --git a/src/detect/manifests/qodercli.toml b/src/detect/manifests/qodercli.toml new file mode 100644 index 00000000..51ca805e --- /dev/null +++ b/src/detect/manifests/qodercli.toml @@ -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}'] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index f2def663..62dc3d05 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -3,7 +3,8 @@ //! Each pane's live bottom-of-buffer text is read periodically and matched //! against known agent output patterns to determine state. -mod agents; +pub mod manifest; +pub mod manifest_update; /// The detected state of a terminal pane. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -25,6 +26,8 @@ pub struct AgentDetection { /// True when the current screen is an agent-owned viewer that shows /// transcript/history instead of the live prompt state. pub skip_state_update: bool, + /// True when the current screen visibly shows live idle chrome. + pub visible_idle: bool, /// True when the current screen visibly shows live UI chrome that needs /// human input. This is stronger than arbitrary prompt-like text in the /// scrollback and may override a non-blocked integration state. @@ -35,12 +38,6 @@ pub struct AgentDetection { pub visible_working: bool, } -/// A narrow screen-derived exception that can veto PTY activity as semantic work. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AgentActivityVeto { - ClaudeIdleRecap, -} - /// Which agent we detected running in a pane. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Agent { @@ -63,6 +60,28 @@ pub enum Agent { Qodercli, } +impl Agent { + pub const ALL: [Self; 17] = [ + Self::Pi, + Self::Claude, + Self::Codex, + Self::Gemini, + Self::Cursor, + Self::Antigravity, + Self::Cline, + Self::OpenCode, + Self::GithubCopilot, + Self::Kimi, + Self::Kiro, + Self::Droid, + Self::Amp, + Self::Grok, + Self::Hermes, + Self::Kilo, + Self::Qodercli, + ]; +} + pub fn agent_label(agent: Agent) -> &'static str { match agent { Agent::Pi => "pi", @@ -179,162 +198,27 @@ pub fn detect_agent(agent: Option, screen_content: &str) -> AgentDetectio return AgentDetection { state: AgentState::Unknown, skip_state_update: false, + visible_idle: false, visible_blocker: false, visible_working: false, }; }; - agents::detect(agent, screen_content) -} - -pub(crate) fn agent_activity_veto( - agent: Option, - screen_content: &str, -) -> Option { - match agent { - Some(Agent::Claude) if agents::claude_code::has_idle_recap_notice(screen_content) => { - Some(AgentActivityVeto::ClaudeIdleRecap) - } - _ => None, - } + manifest::detect(agent, screen_content) } pub fn should_skip_state_update(agent: Option, screen_content: &str) -> bool { - agent.is_some_and(|agent| agents::should_skip_state_update(agent, screen_content)) + agent.is_some_and(|agent| manifest::should_skip_state_update(agent, screen_content)) } -// --------------------------------------------------------------------------- -// Per-agent detectors -// --------------------------------------------------------------------------- - -#[cfg(test)] -fn detect_pi(content: &str) -> AgentState { - detect_state(Some(Agent::Pi), content) -} - -#[cfg(test)] -fn detect_claude(content: &str) -> AgentState { - detect_state(Some(Agent::Claude), content) -} - -#[cfg(test)] -fn detect_codex(content: &str) -> AgentState { - detect_state(Some(Agent::Codex), content) -} - -#[cfg(test)] -fn detect_gemini(content: &str) -> AgentState { - detect_state(Some(Agent::Gemini), content) -} - -#[cfg(test)] -fn detect_cursor(content: &str) -> AgentState { - detect_state(Some(Agent::Cursor), content) -} - -#[cfg(test)] -fn detect_antigravity(content: &str) -> AgentState { - detect_state(Some(Agent::Antigravity), content) -} - -#[cfg(test)] -fn detect_cline(content: &str) -> AgentState { - detect_state(Some(Agent::Cline), content) -} - -#[cfg(test)] -fn detect_opencode(content: &str) -> AgentState { - detect_state(Some(Agent::OpenCode), content) -} - -#[cfg(test)] -fn detect_github_copilot(content: &str) -> AgentState { - detect_state(Some(Agent::GithubCopilot), content) -} - -#[cfg(test)] -fn detect_kimi(content: &str) -> AgentState { - detect_state(Some(Agent::Kimi), content) -} - -#[cfg(test)] -fn detect_droid(content: &str) -> AgentState { - detect_state(Some(Agent::Droid), content) -} - -/// Check for braille spinner characters at the start of a line. -/// These are the Unicode braille pattern dots used by CLI spinners. -fn has_braille_spinner(content: &str) -> bool { - for line in content.lines() { - let trimmed = line.trim(); - if let Some(c) = trimmed.chars().next() { - if ('\u{2800}'..='\u{28FF}').contains(&c) { - return true; - } - } - } - false -} - -#[cfg(test)] -fn detect_qodercli(content: &str) -> AgentState { - detect_state(Some(Agent::Qodercli), content) -} - -#[cfg(test)] -fn detect_kilo(content: &str) -> AgentState { - detect_state(Some(Agent::Kilo), content) -} - -// --------------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------------- - -/// Check for action confirmations followed by "yes" or "❯". -fn has_confirmation_prompt(lower_content: &str) -> bool { - if let Some(pos) = lower_content - .find("do you want to") - .or_else(|| lower_content.find("would you like to")) - { - let after = &lower_content[pos..]; - return after.contains("yes") || after.contains('❯'); - } - false -} - -/// Check for "❯" followed by numbered options like "1." -fn has_selection_prompt(content: &str) -> bool { - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.starts_with('❯') { - // Check if there's a digit followed by a dot nearby - if trimmed.chars().any(|c| c.is_ascii_digit()) && trimmed.contains('.') { - return true; - } - } - } - false -} - -/// Check for real interrupt hints, not unrelated "esc" and "interrupt" text. -fn has_interrupt_pattern(lower_content: &str) -> bool { - lower_content.contains("esc to interrupt") - || lower_content.contains("ctrl+c to interrupt") - || lower_content.contains("press esc to interrupt") -} - -#[cfg(test)] -fn has_cursor_spinner(content: &str) -> bool { - agents::cursor::has_cursor_spinner(content) -} - -#[cfg(test)] -fn content_above_prompt_box(content: &str) -> &str { - agents::claude_code::content_above_prompt_box(content) -} - -#[cfg(test)] -fn has_spinner_activity(content: &str) -> bool { - agents::claude_code::has_spinner_activity(content) +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") + ) } // --------------------------------------------------------------------------- @@ -662,57 +546,10 @@ mod tests { #[test] fn moved_agent_detection_routes_through_production_dispatch() { - let cases = [ - (Agent::Pi, "Working...", AgentState::Working), - ( - Agent::Claude, - "✽ Writing…\nesc to interrupt\n──────\n❯ \n──────", - AgentState::Working, - ), - ( - Agent::Codex, - "generating code\nesc to interrupt", - AgentState::Working, - ), - (Agent::Gemini, "Esc to cancel", AgentState::Working), - (Agent::Cursor, "Ctrl+C to stop", AgentState::Working), - ( - Agent::Antigravity, - "⠋ Thinking through the request", - AgentState::Working, - ), - (Agent::Cline, "Reading project files", AgentState::Working), - ( - Agent::OpenCode, - "running tool\nesc to interrupt", - AgentState::Working, - ), - (Agent::GithubCopilot, "Esc cancel", AgentState::Working), - (Agent::Kimi, "🌕", AgentState::Working), - (Agent::Kiro, "Kiro is working", AgentState::Working), - (Agent::Droid, "Press ESC to stop", AgentState::Working), - (Agent::Amp, "Esc to cancel", AgentState::Working), - ( - Agent::Grok, - "⠋ Waiting… 1.8s\nCtrl+c:cancel Ctrl+Enter:interject", - AgentState::Working, - ), - (Agent::Hermes, "msg=interrupt", AgentState::Working), - ( - Agent::Kilo, - "Ask · DeepSeek V4 Pro\nesc interrupt", - AgentState::Working, - ), - (Agent::Qodercli, "\u{280B} Thinking...", AgentState::Working), - ]; + let detection = detect_agent(Some(Agent::Pi), "Working..."); - for (agent, screen, expected) in cases { - assert_eq!( - detect_agent(Some(agent), screen).state, - expected, - "production dispatch for {agent:?}" - ); - } + assert_eq!(detection.state, AgentState::Working); + assert!(detection.visible_working); } // ---- Agent identification ---- @@ -1079,1852 +916,13 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } - // ---- Workspace state rollup ---- - - // ---- No agent → Unknown ---- + // ---- Screen detection routing ---- #[test] fn no_agent_returns_unknown() { assert_eq!(detect_state(None, "anything"), AgentState::Unknown); } - // ---- Pi ---- - - #[test] - fn pi_working_when_working() { - assert_eq!(detect_pi("some output\nWorking..."), AgentState::Working); - } - - #[test] - fn pi_working_working_in_middle() { - assert_eq!(detect_pi("line1\nWorking...\nline3"), AgentState::Working); - } - - #[test] - fn pi_idle_at_prompt() { - assert_eq!(detect_pi("❯ "), AgentState::Idle); - } - - #[test] - fn pi_idle_no_working_text() { - assert_eq!(detect_pi("some output\n\n> ready"), AgentState::Idle); - } - - // ---- Claude Code ---- - - #[test] - fn claude_working_esc_to_interrupt() { - let screen = "Reading file src/main.rs\nesc to interrupt\n─────────\n❯ \n─────────"; - assert_eq!(detect_claude(screen), AgentState::Working); - } - - #[test] - fn claude_working_ctrl_c_to_interrupt() { - let screen = "Editing code\nctrl+c to interrupt\n─────────\n❯ \n─────────"; - assert_eq!(detect_claude(screen), AgentState::Working); - } - - #[test] - fn claude_working_spinner() { - let screen = "✽ Tempering…\n─────────\n❯ \n─────────"; - assert_eq!(detect_claude(screen), AgentState::Working); - } - - #[test] - fn claude_working_middle_dot_spinner() { - let screen = "· Thinking…\n─────────\n❯ \n─────────"; - assert_eq!(detect_claude(screen), AgentState::Working); - } - - #[test] - fn claude_working_spinner_with_detail() { - let screen = "✳ Simplifying recompute_tangents…\n─────────\n❯ \n─────────"; - assert_eq!(detect_claude(screen), AgentState::Working); - } - - #[test] - fn claude_detailed_transcript_skips_state_update() { - let screen = "● I read the root and README.md.\n\n✻ Cogitated for 14s\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Showing detailed transcript · ctrl+o to toggle · ctrl+e to show all verbose"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert!(detection.skip_state_update); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_detailed_transcript_collapse_skips_state_update() { - let screen = "● Running tool\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Showing detailed transcript · ctrl+o to toggle · ctrl+e to collapse"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert!(detection.skip_state_update); - } - - #[test] - fn claude_wrapped_detailed_transcript_controls_skip_state_update() { - let screen = "● Running tool\n\n────────────────────────\n Showing detailed transcript · ctrl+o\n to toggle · ctrl+e to\n collapse"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert!(detection.skip_state_update); - } - - #[test] - fn claude_transcript_text_above_prompt_does_not_skip_state_update() { - let screen = "Docs mention: Showing detailed transcript · ctrl+o to toggle · ctrl+e to show all\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n❯ \n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert!(!detection.skip_state_update); - } - - #[test] - fn claude_waiting_do_you_want() { - let screen = "Do you want to run this command?\n\nYes No"; - assert_eq!(detect_claude(screen), AgentState::Blocked); - } - - #[test] - fn claude_waiting_would_you_like() { - let screen = "Would you like to apply these changes?\n\n❯ Yes"; - assert_eq!(detect_claude(screen), AgentState::Blocked); - } - - #[test] - fn claude_waiting_selection_prompt() { - let screen = "Do you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend"; - assert_eq!(detect_claude(screen), AgentState::Blocked); - } - - #[test] - fn claude_waiting_esc_to_cancel() { - let screen = "Allow bash: rm -rf /tmp/test?\n\nDo you want to proceed?\n\nesc to cancel"; - assert_eq!(detect_claude(screen), AgentState::Blocked); - } - - #[test] - fn claude_bash_permission_modal_is_visible_blocker() { - let screen = "● Bash(mkdir -p /tmp/herdr-claude-detector-test && for i in 1 2 3; do dd if=/dev/urandom)\n ⎿ Waiting…\n\n────────────────────────\n Bash command\n\n mkdir -p /tmp/herdr-claude-detector-test && ls -la /tmp/herdr-claude-detector-test\n Create random files in temporary detector directory\n\n Contains expansion\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. No\n\n Esc to cancel · Tab to amend · ctrl+e to explain"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn claude_cropped_bash_permission_modal_is_visible_blocker() { - let screen = "● Bash(mkdir -p /tmp/herdr-claude-detector-test && ls -la /tmp/herdr-claude-detector-test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn claude_waiting_ask_user_question_menu() { - let screen = - "Which approach should I take?\n❯ 1. Minimal change\n 2. Bigger refactor\n3. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel"; - assert_eq!(detect_claude(screen), AgentState::Blocked); - } - - #[test] - fn claude_dynamic_workflow_prompt_is_visible_blocker() { - let screen = "● Workflow(Review the current worktree diff and verify the resulting findings)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n Run a dynamic workflow?\n\n Review the current worktree diff and verify the resulting findings\n\n This dynamic workflow will spin up multiple subagents across the following phases:\n 1. Overview — map the diff and identify coupled subsystems\n 2. Review — review each changed file in parallel\n 3. Verify — adversarially verify each finding\n 4. Synthesize — summarize confirmed issues and residual risk\n\n Dynamic workflows can use a lot of tokens quickly by running many subagents in parallel — which counts against your usage limit. Stop a running workflow at any time with\n /workflows, or disable dynamic workflows in /config.\n\n ❯ 1. Yes, run it\n 2. View raw script\n 3. No\n\n Esc to cancel · Tab to amend\n ctrl+g to edit script in $EDITOR"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - assert!(!detection.visible_working); - } - - #[test] - fn claude_dynamic_workflow_prompt_uses_stable_modal_gate() { - let screen = "● Workflow(Evaluate changes)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n Run a dynamic workflow?\n\n 1. Scope\n 2. Review\n 3. Synthesize\n\n Esc to cancel · Tab to amend"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn claude_question_form_selected_top_is_visible_blocker() { - let screen = "❯ ask again\n─────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Subject ☐ Tone ✔ Submit →\n\nWhat should I ask you about?\n\n❯ 1. Today\n Your current plan or priority.\n 2. Project\n A codebase, feature, bug, or PR.\n 3. Preference\n How you want me to work with you.\n 4. Random\n A casual question with no work context.\n 5. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n 6. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn claude_question_form_with_arrow_glyph_footer_is_visible_blocker() { - let screen = "❯ this is a test can you use question otol to ask questiosns\n\n Thought for 16s (ctrl+o to expand)\n─────────────────────────────────────────────────────────────────────────────────────────\n ☐ Test type\n\nWhich kind of test question should I ask you next?\n\n❯ 1. Single choice\n Ask one multiple-choice question.\n 2. Multi choice\n Ask one question that allows several answers.\n 3. With preview\n Ask with side-by-side previews.\n 4. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n 5. Chat about this\n\nEnter to select · ↑/↓ to navigate · Esc to cancel"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn claude_question_form_selected_bottom_is_visible_blocker() { - let screen = "❯ ask again\n─────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Subject ☐ Tone ✔ Submit →\n\nWhat should I ask you about?\n\n 1. Today\n Your current plan or priority.\n 2. Project\n A codebase, feature, bug, or PR.\n 3. Preference\n How you want me to work with you.\n 4. Random\n A casual question with no work context.\n 5. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ 6. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · ctrl+g to edit in Zed · Esc to cancel"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn claude_idle_hooks_menu() { - let screen = "Hooks\n0 hooks configured\nℹ This menu is read-only. To add or modify hooks, edit settings.json directly or ask Claude. Learn more\n\n❯ 1. PreToolUse\n 2. PostToolUse\n 3. PostToolUseFailure\n\nEnter to confirm · Esc to cancel"; - assert_eq!(detect_claude(screen), AgentState::Idle); - } - - #[test] - fn claude_idle_theme_menu() { - let screen = "Theme\nChoose the text style that looks best with your terminal\n\n❯ 1. Dark mode ✔\n 2. Light mode\n 3. Dark mode (colorblind-friendly)\n\nSyntax theme: Monokai Extended (ctrl+t to disable)\n\nEnter to select · Esc to cancel"; - assert_eq!(detect_claude(screen), AgentState::Idle); - } - - #[test] - fn claude_idle_prompt_box() { - let screen = "Task complete.\n─────────────\n❯ \n─────────────"; - assert_eq!(detect_claude(screen), AgentState::Idle); - } - - #[test] - fn claude_idle_recap_notice_is_activity_veto() { - let screen = "✻ Churned for 9s\n\n※ recap: We’re debugging issue 494: kitty graphics render in shell panes but not inside Neovim. Next, I’m tracing the snacks.nvim inline-image path against Herdr’s placeholder\n handling to confirm the exact failure point. (disable recaps in /config)\n\n────────────────────────────────────────────────────────────────────────────────\n❯ \n────────────────────────────────────────────────────────────────────────────────"; - - assert_eq!( - agent_activity_veto(Some(Agent::Claude), screen), - Some(AgentActivityVeto::ClaudeIdleRecap) - ); - } - - #[test] - fn wrapped_claude_idle_recap_notice_is_activity_veto() { - let screen = "※ recap: We’re debugging issue 494: kitty graphics render in shell panes but\n not inside Neovim. Next, I’m tracing the snacks.nvim inline-image path\n against Herdr’s placeholder handling to confirm the exact failure point…\n and then I will inspect the redraw cache. (disable recaps in /config)\n\n────────────────────────────────────────────────────────────────────────────────\n❯ \n────────────────────────────────────────────────────────────────────────────────"; - - assert_eq!( - agent_activity_veto(Some(Agent::Claude), screen), - Some(AgentActivityVeto::ClaudeIdleRecap) - ); - } - - #[test] - fn stale_claude_recap_above_new_work_does_not_veto_activity() { - let screen = "※ recap: Old recap. (disable recaps in /config)\n\n● Reading src/pane.rs\n\n✻ Crunching…\n\n────────────────────────────────────────────────────────────────────────────────\n❯ \n────────────────────────────────────────────────────────────────────────────────"; - - assert_eq!(agent_activity_veto(Some(Agent::Claude), screen), None); - } - - #[test] - fn claude_prompt_box_does_not_set_screen_idle_signal() { - let screen = "Interrupted.\n─────────────\n❯ \n─────────────"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - } - - #[test] - fn claude_prompt_box_with_status_text_and_custom_status_does_not_set_screen_idle_signal() { - let screen = "──────────────────────────────────────────── ◐ medium · /effort\n❯ \n────────────────────────────────────────────\n thommie-backend | refactor/cleanup-codebase | Opus 4.8 (1M context)\nIppy Tippy\n/coach-dive to chat about this\n▸▸ auto mode on (shift+tab to cycle) · ← for agents"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_interrupted_permission_prompt_box_does_not_set_screen_idle_signal() { - let screen = "❯ this is a test, create some dummy files on /tmp and -rm rf them i wanna test\n permissions\n\n Thought for 7s (ctrl+o to expand)\n\n● Bash(tmpdir=$(mktemp -d /tmp/claude-perm-test.XXXXXX) && touch \"$tmpdir/file1.txt\"\n \"$tmpdir/file2.log\" && mkdir \"$tmpdir/subdir\" && touch\n \"$tmpdir/subdir/nested.txt\"…)\n ⎿ Interrupted · What should Claude do instead?\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_declined_questions_in_scrollback_with_prompt_box_are_idle() { - let screen = "● User declined to answer questions\n ⎿ · What do you want help with? (Code task / PR review / Research / Claude setup)\n · How detailed should I be? (Short / Medium / Detailed)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~ ⊘ no git ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_old_permission_prompt_with_live_prompt_box_is_idle() { - let screen = "● Bash(rm -rf /tmp/test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend · ctrl+e to explain\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_old_permission_prompt_above_wrapped_live_prompt_is_not_visible_blocker() { - let screen = "● Bash(rm -rf /tmp/test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend · ctrl+e to explain\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ explain why this stale approval prompt should not be considered live anymore because\n my current prompt wrapped onto a second line\n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert!(!detection.visible_blocker); - assert_eq!( - crate::agent_detection_policy::apply_detection_policy( - crate::agent_detection_policy::DetectionPolicyInput { - agent: Some(Agent::Claude), - screen_detection: detection, - process_exited: false, - startup_grace_active: false, - pty_signal: Some(crate::agent_detection_policy::PtySignal { - active: false, - tainted: false, - }), - }, - ), - crate::agent_detection_policy::DetectionPolicyDecision::Publish(AgentDetection { - state: AgentState::Idle, - skip_state_update: false, - visible_blocker: false, - visible_working: false, - }) - ); - } - - #[test] - fn claude_spinner_after_interrupted_permission_is_visible_working() { - let screen = "❯ this is a test, create some dummy files on /tmp and -rm rf them i wanna test\n permissions\n\n Thought for 7s (ctrl+o to expand)\n\n● Bash(tmpdir=$(mktemp -d /tmp/claude-perm-test.XXXXXX) && touch \"$tmpdir/file1.txt\"\n \"$tmpdir/file2.log\" && mkdir \"$tmpdir/subdir\" && touch\n \"$tmpdir/subdir/nested.txt\"…)\n ⎿ Interrupted · What should Claude do instead?\n\n❯ test\n\n✢ Garnishing… (1s · thinking with high effort)\n ⎿ Tip: Run claude --continue or claude --resume to resume a conversation\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_separators_without_prompt_do_not_set_screen_idle_signal() { - let screen = "Task complete.\n─────────────\nplain text\n─────────────"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - } - - #[test] - fn claude_spinner_above_prompt_box_is_working() { - let screen = "✢ Imagining… (3s · thinking with high effort)\n ⎿ Tip: Run /terminal-setup\n\n─────────────\n❯ \n─────────────\n~/project"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn claude_waiting_for_background_agent_is_working() { - let screen = "● Done. I’ve delegated a read-only repo investigation to a subagent, and it will come back with a detailed report.\n\n✻ Waiting for 1 background agent to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/llm-proxy ⎇ master ▱▱▱▱▱ 0%\n\n ● main ↑/↓ to select · Enter to view\n ◯ Explore Investigate repo and report 33s · ↓ 225 tokens"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_waiting_for_multiple_background_agents_is_working() { - let screen = "● Done. I’ve delegated two investigations.\n\n✻ Waiting for 2 background agents to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn claude_completed_background_agent_wait_in_scrollback_is_idle() { - let screen = "❯ please create a background agent that sleeps 15 sec then say hi\n\n Thought for 8s (ctrl+o to expand)\n\n● claude(Sleep then say hi)\n ⎿ Backgrounded agent (↓ to manage · ctrl+o to expand)\n\n● Done. I launched a background agent that will wait 15 seconds and then reply with hi.\n\n✻ Waiting for 1 background agent to finish\n\n● Agent \"Sleep then say hi\" completed · 17s\n\n● hi\n\n✻ Brewed for 28s\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_completed_background_agent_with_prompt_box_is_idle() { - let screen = "● Done. I’ve started a background agent.\n\n✻ Waiting for 1 background agent to finish\n\n⏺ Agent \"Sleep then say hi\" completed · 31s\n\n⏺ hi\n\n✻ Baked for 42s\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%\n\n ● main ↑/↓ to select · Enter to view\n ◯ general-purpose Sleep then say hi 31s"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn claude_zero_background_agent_wait_with_prompt_box_is_idle() { - let screen = "✻ Waiting for 0 background agents to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - } - - #[test] - fn claude_background_agent_wait_phrase_in_prose_is_idle() { - let screen = "Claude mentioned: Waiting for 1 background agent to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; - let detection = detect_agent(Some(Agent::Claude), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - } - - #[test] - fn claude_working_not_confused_by_old_prompt() { - // The "esc to interrupt" is ABOVE the prompt box — should be working - let screen = "✽ Writing…\nesc to interrupt\n──────\n❯ \n──────"; - assert_eq!(detect_claude(screen), AgentState::Working); - } - - // ---- Codex ---- - - #[test] - fn codex_waiting_confirm() { - assert_eq!( - detect_codex("press enter to confirm or esc to cancel"), - AgentState::Blocked - ); - } - - #[test] - fn codex_waiting_allow_command() { - assert_eq!(detect_codex("allow command?\n[y/n]"), AgentState::Blocked); - } - - #[test] - fn codex_waiting_submit_answer() { - assert_eq!( - detect_codex("Question about approach\n| enter to submit answer"), - AgentState::Blocked - ); - } - - #[test] - fn codex_question_ui_is_visible_blocker() { - let screen = "Question 1/1 (1 unanswered)\nWhat kind of code improvement do you want?\n› 1. Reduce complexity\n 2. Improve reliability\n\ntab to add notes | enter to submit answer | esc to interrupt"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn codex_bare_yes_no_hint_is_not_visible_blocker() { - let detection = detect_agent(Some(Agent::Codex), "The docs mention [y/n] prompts."); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(!detection.visible_blocker); - } - - #[test] - fn codex_replayed_transcript_weak_blocked_text_above_prompt_is_not_visible_blocker() { - let screen = "Codex\nBlocked signals in src/detect/agents/codex.rs:6: confirm footer, submit answer/all, allow command, [y/n], yes (y), or generic confirmation.\n\nLikely false positives: [y/n] and generic confirmation prose still mark Blocked.\n\n• Agent thread 019e7670-ba31-7641-b6e0-545c101de8c3 is closed. Replaying saved transcript.\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master · gpt-5.5 default · Context 37% used"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert!(!detection.visible_blocker); - } - - #[test] - fn codex_replayed_allow_command_text_above_prompt_is_idle() { - let screen = "• Ran grep for blockers\n └ The phrase allow command? appears in our discussion above.\n\n■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.\n\n\n› Write tests for @filename\n\n gpt-5.5 high · ~/.herdr/worktrees/herdr/plugin-v1 · plugin-v1 · Context 18% used · 5h 93% left · weekly 71% left"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_blocker); - } - - #[test] - fn codex_generic_confirmation_prompt_is_not_visible_blocker() { - let detection = detect_agent( - Some(Agent::Codex), - "Earlier output asked: do you want to continue? The answer was yes.", - ); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(!detection.visible_blocker); - } - - #[test] - fn gemini_confirmation_prompt_is_visible_blocker() { - let detection = detect_agent(Some(Agent::Gemini), "Do you want to proceed?\n\nYes No"); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn codex_waiting_submit_answer_wrapped_footer() { - assert_eq!( - detect_codex( - "Question 1/2\nChoose an option.\nenter to submit answer\nesc to interrupt" - ), - AgentState::Blocked - ); - } - - #[test] - fn codex_waiting_submit_all_multi_question_footer() { - let screen = "Question 2/2 (1 unanswered)\nAt a high level, what is issue 249 supposed to fix?\n› 1. State arbitration (Recommended)\n 2. UI behavior\n 3. Test reliability\n 4. None of the above\n\ntab to add notes | enter to submit all | ←/→ to navigate questions | esc to interrupt"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn codex_interrupted_prompt_does_not_set_screen_idle_signal() { - let screen = "■ Conversation interrupted - tell the model what to do differently. Something went\nwrong? Hit `/feedback` to report the issue.\n\n\n› Run /review on my current changes\n\n gpt-5.5 high · ~/Projects/herdr-worktrees/issue-249-state-arbitration"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert_eq!(detection.state, AgentState::Idle); - } - - #[test] - fn codex_working_interrupt() { - assert_eq!( - detect_codex("generating code\nesc to interrupt"), - AgentState::Working - ); - } - - #[test] - fn codex_working_truncated_status_header() { - assert_eq!(detect_codex("• Working (0s • esc…"), AgentState::Working); - } - - #[test] - fn codex_status_line_is_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Ran git status --short\n └ M src/detect.rs\n\n• Working (17s • esc to interrupt)\n\n\n› Implement {feature}", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_live_working_status_above_current_prompt_stays_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Ran git diff --name-status v0.6.3..HEAD\n └ A website/src/pages/releases/index.astro\n\n• Working (28s • esc to interrupt)\n\n\n› Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used · 5h 96% left", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_generic_interrupt_status_above_current_prompt_stays_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• I need to inspect more code first.\n\n• Investigating code output (44s • esc to interrupt)\n\n\n› Summarize recent commits\n\n gpt-5.5 high · ~/Codes/herdr", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_truncated_status_with_arbitrary_label_is_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Ran git diff -- src/detect/mod.rs\n └ diff output...\n\n• Considering patch updates (2m 26s • esc …\n\n\n› Fix Codex detection\n\n ~/Projects/herdr · master", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn codex_truncated_status_without_prompt_is_working() { - assert_eq!( - detect_codex("• Considering patch updates (2m 26s • esc …"), - AgentState::Working - ); - } - - #[test] - fn codex_bullet_with_esc_text_without_timer_is_not_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Notes mention esc to interrupt as a phrase\n\n› Fix Codex detection\n\n ~/Projects/herdr · master", - ); - - assert!(!detection.visible_blocker); - assert!(!detection.visible_working); - } - - #[test] - fn codex_background_terminal_status_above_current_prompt_stays_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Ran just check\n └ test output...\n\n• Working (1m 36s) · 1 background terminal running · /ps to view · /stop to close\n\n\n› Run /review on my current changes", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_reviewing_approval_request_is_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Reviewing approval request (22s • esc to interrupt)\n └ /bin/zsh -lc 'rm -rf /tmp/codex-rm-test'\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_reviewing_approval_request_without_prompt_is_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Running git push --force-with-lease origin codex/ci-flake-diagnostics\n\n• Reviewing approval request (2m 53s • esc to interrupt)\n └ /bin/zsh -lc 'git push --force-with-lease origin codex/ci-flake-diagnostics'", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_reviewing_multiple_approval_requests_is_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Reviewing 2 approval requests (0s • esc to interrupt)\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_booting_mcp_server_is_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Booting MCP server: codex_apps (7s • esc to interrupt)\n\n\n› ok spawn one again please\n\n ~/Projects/herdr · master", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_stale_working_status_above_current_prompt_is_not_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "■ Conversation interrupted - tell the model what to do differently. Something went wrong?\nHit `/feedback` to report the issue.\n\n\n› `/feedback` to report the issue.\n\n\n › Working\n\n\n • Working (2s • esc to interrupt)\n\n\n › Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used\n\n\n■ Conversation interrupted - tell the model what to do differently. Something went wrong?\nHit `/feedback` to report the issue.\n\n\n› Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used · 5h 95% left", - ); - - assert!(!detection.visible_working); - } - - #[test] - fn codex_pasted_transcript_in_current_prompt_does_not_trigger_working() { - let detection = detect_agent( - Some(Agent::Codex), - "› so there is a problem\n\n\n\n\n • Working (2s • esc to interrupt)\n\n\n › Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 0% used · 5h 94% left · weekly\n 36% …\n\n wdyt\n\n\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 0% used · 5h 94% left · weekly 36% …", - ); - - assert!(!detection.visible_working); - } - - #[test] - fn codex_background_terminal_wait_is_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Waiting for background terminal (0s • esc to …\n └ cargo test -p codex-core -- --exact…\n\n\n› Ask Codex to do anything", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn codex_working_header_without_interrupt_is_not_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Working (17s)\n\n› Implement {feature}", - ); - - assert_eq!(detection.state, AgentState::Working); - assert!(!detection.visible_working); - } - - #[test] - fn codex_old_working_line_before_later_block_is_not_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Working (17s • esc to interrupt)\n\n• Ran git status --short\n └ M src/detect.rs\n\n› Implement {feature}", - ); - - assert!(!detection.visible_working); - } - - #[test] - fn codex_old_background_terminal_wait_before_later_block_is_not_visible_working() { - let detection = detect_agent( - Some(Agent::Codex), - "• Waiting for background terminal (0s • esc to …\n └ cargo test -p codex-core\n\n• Ran git status --short\n └ M src/detect.rs\n\n› Implement {feature}", - ); - - assert!(!detection.visible_working); - } - - #[test] - fn codex_queued_follow_up_keeps_active_working_status() { - let cases = [ - "• Working (9m 24s • esc to interrupt)\n\n • Queued follow-up inputs\n ↳ also a new bug we can talk about later\n alt + ↑ edit last queued message\n\n› Summarize recent commits\n\n ~/Projects/herdr · master", - "• Working (9m 24s • esc to interrupt)\n\n• Queued follow-up inputs\n ↳ also a new bug we can talk about later\n alt + ↑ edit last queued message\n\n› Summarize recent commits\n\n ~/Projects/herdr · master", - "• Working (4s • esc to interrupt)\n\n • Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ you mean that\n\n› Summarize recent commits\n\n ~/Projects/herdr · master", - "• Working (4s • esc to interrupt)\n\n• Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ you mean that\n\n› Summarize recent commits\n\n ~/Projects/herdr · master", - ]; - - for screen in cases { - let detection = detect_agent(Some(Agent::Codex), screen); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - } - - #[test] - fn codex_idle() { - assert_eq!(detect_codex("❯ "), AgentState::Idle); - } - - #[test] - fn codex_transcript_viewer_skips_state_update() { - let screen = "/ T R A N S C R I P T / / / / / / / / / / / / / / / /\n\n› i did thats why our latest commit is also a claude fix D\n\n• Yes, then I would release.\n──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert!(detection.skip_state_update); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - #[test] - fn codex_wrapped_transcript_controls_skip_state_update() { - let screen = "/ T R A N S C R I P T /\n\n› yeah go ahead\n──────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to\n page home/end to jump\n q to quit esc to edit prev"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert!(detection.skip_state_update); - } - - #[test] - fn codex_esc_transcript_controls_skip_state_update() { - let screen = "/ 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 → to edit next enter to edit message"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert!(detection.skip_state_update); - } - - #[test] - fn codex_transcript_controls_above_prompt_do_not_skip_state_update() { - let screen = "Old output:\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev\n\n› "; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert!(!detection.skip_state_update); - } - - #[test] - fn codex_exit_control_without_scroll_control_does_not_skip_state_update() { - let screen = "Some output\n\n q to quit esc to edit prev"; - let detection = detect_agent(Some(Agent::Codex), screen); - - assert!(!detection.skip_state_update); - } - - // ---- Gemini ---- - - #[test] - fn gemini_waiting_confirmation() { - assert_eq!( - detect_gemini("waiting for user confirmation\nYes No"), - AgentState::Blocked - ); - } - - #[test] - fn gemini_waiting_text_without_choices_is_idle() { - assert_eq!( - detect_gemini("waiting for user confirmation"), - AgentState::Idle - ); - } - - #[test] - fn gemini_waiting_apply() { - assert_eq!( - detect_gemini("│ Apply this change\n│ Yes │ No"), - AgentState::Blocked - ); - } - - #[test] - fn gemini_waiting_allow_execution() { - assert_eq!( - detect_gemini("│ Allow execution of: rm test.txt"), - AgentState::Blocked - ); - } - - #[test] - fn gemini_working() { - assert_eq!( - detect_gemini("thinking...\nesc to cancel"), - AgentState::Working - ); - } - - #[test] - fn gemini_idle() { - assert_eq!(detect_gemini("❯ "), AgentState::Idle); - } - - // ---- Cursor ---- - - #[test] - fn cursor_waiting_accept() { - assert_eq!( - detect_cursor("Apply changes? (y) (enter) or keep (n)"), - AgentState::Blocked - ); - } - - #[test] - fn cursor_waiting_allow() { - assert_eq!(detect_cursor("allow file edit (y)"), AgentState::Blocked); - } - - #[test] - fn cursor_working_spinner() { - assert_eq!(detect_cursor("⬡ Grepping.."), AgentState::Working); - } - - #[test] - fn cursor_working_braille_status() { - assert_eq!( - detect_cursor("⠠⠜ Running 5.52k tokens"), - AgentState::Working - ); - assert_eq!( - detect_cursor("⠞ Working 5.62k tokens"), - AgentState::Working - ); - assert_eq!( - detect_cursor("⠛ Grepping 1.2k tokens"), - AgentState::Working - ); - } - - #[test] - fn cursor_blocked_command_approval() { - let screen = - "Waiting for approval...\nRun this command?\n→ Run (once) (y)\nSkip (esc or n)"; - assert_eq!(detect_cursor(screen), AgentState::Blocked); - } - - #[test] - fn cursor_approval_words_without_visible_controls_are_idle() { - assert_eq!( - detect_cursor("previous output: waiting for approval"), - AgentState::Idle - ); - } - - #[test] - fn cursor_running_text_with_unrelated_yes_is_not_blocked() { - let screen = "previous answer mentioned (y)\n⠠⠜ Running 5.52k tokens"; - assert_eq!(detect_cursor(screen), AgentState::Working); - } - - #[test] - fn cursor_working_ctrl_c() { - assert_eq!( - detect_cursor("processing\nctrl+c to stop"), - AgentState::Working - ); - } - - #[test] - fn cursor_idle() { - assert_eq!(detect_cursor("> "), AgentState::Idle); - } - - // ---- Antigravity ---- - - #[test] - fn antigravity_blocked_permission_prompt() { - let screen = "Requesting permission for: git log -n 50\nDo you want to proceed?\n> 1. Yes\n↑/↓ Navigate · tab Amend · e edit command"; - assert_eq!(detect_antigravity(screen), AgentState::Blocked); - } - - #[test] - fn antigravity_question_without_permission_request_stays_idle() { - assert_eq!( - detect_antigravity("Do you want to proceed?\n>"), - AgentState::Idle - ); - } - - #[test] - fn antigravity_working_spinner() { - assert_eq!(detect_antigravity("⡿ Working..."), AgentState::Working); - assert_eq!(detect_antigravity("⣯ Loading..."), AgentState::Working); - assert_eq!(detect_antigravity("⢿ Generating..."), AgentState::Working); - assert_eq!(detect_antigravity("⣷ Running..."), AgentState::Working); - } - - #[test] - fn antigravity_background_task_footer_is_working() { - let screen = "? for shortcuts Gemini 3.5 Flash (High) · 1 task(s) · /tasks"; - assert_eq!(detect_antigravity(screen), AgentState::Working); - } - - #[test] - fn antigravity_background_task_footer_parses_plural_variants() { - assert_eq!( - detect_antigravity("Gemini 3.5 Flash (High) · 10 task(s) · /tasks"), - AgentState::Working - ); - assert_eq!( - detect_antigravity("model · 2 tasks · /tasks"), - AgentState::Working - ); - assert_eq!( - detect_antigravity("model · 1 task · /tasks"), - AgentState::Working - ); - } - - #[test] - fn antigravity_zero_background_tasks_is_idle() { - let screen = "Gemini 3.5 Flash (High) · 0 task(s) · /tasks"; - assert_eq!(detect_antigravity(screen), AgentState::Idle); - } - - #[test] - fn antigravity_task_text_outside_bottom_footer_is_idle() { - let screen = - "User said the footer had /tasks and 1 task(s)\nline 1\nline 2\nline 3\nline 4\nline 5"; - assert_eq!(detect_antigravity(screen), AgentState::Idle); - } - - #[test] - fn antigravity_idle_prompt() { - let screen = "Antigravity CLI\n────────────────\n>\n────────────────\n? for shortcuts"; - assert_eq!(detect_antigravity(screen), AgentState::Idle); - } - - // ---- Cline ---- - - #[test] - fn cline_waiting_tool_use() { - assert_eq!(detect_cline("let cline use this tool"), AgentState::Blocked); - } - - #[test] - fn cline_waiting_act_mode() { - assert_eq!( - detect_cline("[act mode] execute command?\nyes"), - AgentState::Blocked - ); - } - - #[test] - fn cline_mode_text_with_unrelated_yes_is_not_blocked() { - assert_eq!( - detect_cline("[act mode]\nPrevious answer was yes."), - AgentState::Working - ); - } - - #[test] - fn cline_ready_text_is_not_visible_blocker() { - let detection = detect_agent(Some(Agent::Cline), "cline is ready for your message"); - - assert!(!detection.visible_blocker); - } - - #[test] - fn cline_defaults_to_working() { - // Cline's default is working (unlike other agents) - assert_eq!(detect_cline("some random output"), AgentState::Working); - } - - // ---- OpenCode ---- - - #[test] - fn opencode_waiting_permission() { - assert_eq!( - detect_opencode("△ Permission required"), - AgentState::Blocked - ); - } - - #[test] - fn opencode_working() { - assert_eq!( - detect_opencode("running tool\nesc to interrupt"), - AgentState::Working - ); - } - - #[test] - fn opencode_working_on_footer_interrupt() { - let screen = "\ - ▣ Build · MiniMax M3 Free\n\ -\n\ - ┃\n\ - ┃ Build · MiniMax M3 Free OpenCode Zen ~/Projects/llm-proxy:master\n\ - ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n\ - ⬝⬝⬝■■■■■ esc interrupt 24.4K (12%) ctrl+p commands • OpenCode 1.15.13"; - assert_eq!(detect_opencode(screen), AgentState::Working); - } - - #[test] - fn opencode_working_on_progress_footer_without_product_text() { - assert_eq!( - detect_opencode("■■■■■■⬝⬝ esc interrupt"), - AgentState::Working - ); - } - - #[test] - fn opencode_working_on_escape_again_footer() { - assert_eq!( - detect_opencode( - "⬝⬝■■■■■■ esc again to interrupt 14.3K (7%) ctrl+p commands • OpenCode 1.15.13" - ), - AgentState::Working - ); - } - - #[test] - fn opencode_progress_row_alone_is_working() { - assert_eq!(detect_opencode("■■■■■■⬝⬝"), AgentState::Working); - } - - #[test] - fn opencode_ctrl_p_commands_alone_is_not_working() { - assert_eq!( - detect_opencode("esc interrupt 24.4K (12%) ctrl+p commands"), - AgentState::Idle - ); - } - - #[test] - fn opencode_waiting_question_prompt() { - assert_eq!( - detect_opencode( - "Goal Detail Confirm\n\ - What do you want help with right now?\n\ - 1. Code change\n\ - 5. Type your own answer\n\ - ⇆ tab ↑↓ select enter confirm esc dismiss", - ), - AgentState::Blocked - ); - } - - #[test] - fn opencode_waiting_question_confirm_tab() { - assert_eq!( - detect_opencode("Goal Detail Confirm\nReview\n⇆ tab enter submit esc dismiss"), - AgentState::Blocked - ); - } - - #[test] - fn opencode_idle() { - assert_eq!(detect_opencode("> "), AgentState::Idle); - } - - // ---- Kilo ---- - - #[test] - fn kilo_waiting_question_prompt() { - assert_eq!( - detect_kilo( - "Write the following joke to ~/joke.md?\n\ - 1. Yes, write it\n\ - 2. No, thanks\n\ - 3. Type your own answer\n\ - ↑↓ select enter submit esc dismiss", - ), - AgentState::Blocked - ); - } - - #[test] - fn kilo_working() { - assert_eq!( - detect_kilo("Ask · DeepSeek V4 Pro\nesc interrupt\n12.8K (1%) · $0.01"), - AgentState::Working - ); - } - - #[test] - fn kilo_idle() { - assert_eq!( - detect_kilo("Ask anything... \"Fix broken tests\"\nCode · DeepSeek V4 Pro"), - AgentState::Idle - ); - } - - // ---- GitHub Copilot ---- - - #[test] - fn copilot_waiting_fetch_approval() { - let content = "\ -╭───────────────────────────────────────────────────────────────────╮ -│ Fetch web content │ -│ ───────────────────────────────────────────────────────────────── │ -│ Copilot is attempting to access the following URL: │ -│ │ -│ ╭───────────────────────────────────────────────────────────────╮ │ -│ │ https://www.google.com/ │ │ -│ ╰───────────────────────────────────────────────────────────────╯ │ -│ │ -│ Do you want to allow this access? │ -│ │ -│ ❯ 1. Yes │ -│ 2. No │ -│ │ -│ ↑/↓ to navigate · enter to select · esc to cancel │ -╰───────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_waiting_allow_directory_access() { - let content = "\ -╭──────────────────────────────────────────────────────────────────╮ -│ Allow directory access │ -│ ──────────────────────────────────────────────────────────────── │ -│ This action may read or write the following path outside your │ -│ allowed directory list. │ -│ │ -│ ╭──────────────────────────────────────────────────────────────╮ │ -│ │ /Users/user/Dev/workspace │ │ -│ ╰──────────────────────────────────────────────────────────────╯ │ -│ │ -│ Do you want to allow this? │ -│ │ -│ 1. Yes │ -│ ❯ 2. Yes, and add these directories to the allowed list │ -│ 3. No (Esc) │ -│ │ -│ ↑/↓ to navigate · enter to select · esc to cancel │ -╰──────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_waiting_directory_permission() { - let content = "\ -○ Asking user Requesting permission to access directory 'src/' for r… - -╭───────────────────────────────────────────────────────────────────╮ -│ Question │ -│ ───────────────────────────────────────────────────────────────── │ -│ Requesting permission to access directory 'src/' for reading │ -│ files. Allow? │ -│ │ -│ ❯ 1. Yes (Allow) │ -│ 2. No (Deny) │ -│ │ -│ ↑/↓ to select · enter to confirm · esc to cancel │ -╰───────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_waiting_action_confirmation() { - let content = "\ -○ Asking user Confirm action: 'Reset local changes in working tree' … - -╭───────────────────────────────────────────────────────────────────╮ -│ Question │ -│ ───────────────────────────────────────────────────────────────── │ -│ Confirm action: 'Reset local changes in working tree' — proceed? │ -│ │ -│ ❯ 1. Yes, reset changes │ -│ 2. No, cancel │ -│ │ -│ ↑/↓ to select · enter to confirm · esc to cancel │ -╰───────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_waiting_db_choice() { - let content = "\ -○ Asking user Choose a database for the project: - -╭───────────────────────────────────────────────────────────────────╮ -│ Question │ -│ ───────────────────────────────────────────────────────────────── │ -│ Choose a database for the project: │ -│ │ -│ ❯ 1. PostgreSQL (Recommended) │ -│ 2. SQLite │ -│ 3. MySQL │ -│ 4. MongoDB │ -│ │ -│ ↑/↓ to select · enter to confirm · esc to cancel │ -╰───────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_waiting_freeform_input() { - let content = "\ -○ Asking user Enter the name for the new branch: - -╭───────────────────────────────────────────────────────────────────╮ -│ Question │ -│ ───────────────────────────────────────────────────────────────── │ -│ Enter the name for the new branch: │ -│ │ -│ ❯ Type your answer... │ -│ │ -│ enter to submit · esc to cancel │ -╰───────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_waiting_plan_review() { - let content = "\ -○ Plan ready for review - Create a 200-word inspirational poem. - Fi… - -╭───────────────────────────────────────────────────────────────────╮ -│ Plan Ready for Review │ -│ ───────────────────────────────────────────────────────────────── │ -│ - Create a 200-word inspirational poem. │ -│ - Files/changes: none (deliver poem in chat). │ -│ - Steps: draft poem, verify ~200 words, offer up to 2 revisions. │ -│ - Decision: Inspirational tone, exact length target ~200 words. │ -│ │ -│ ❯ 1. Accept plan and build on default permissions (recommended) │ -│ 2. Exit plan mode and I will prompt myself │ -│ 3. Suggest changes │ -│ │ -│ ↑/↓ to navigate · enter to select · ctrl+e to show full plan · │ -│ esc to cancel │ -╰───────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_waiting_enable_autopilot() { - let content = "\ -╭───────────────────────────────────────────────────────────────────╮ -│ Enable autopilot mode │ -│ ───────────────────────────────────────────────────────────────── │ -│ Autopilot mode works best with all permissions enabled. Without │ -│ them, permission requests will be auto-denied and the agent may │ -│ not complete tasks requiring file edits or shell commands. │ -│ │ -│ You can also enable permissions later with /allow-all │ -│ │ -│ ❯ 1. Enable all permissions (recommended) │ -│ 2. Continue with limited permissions │ -│ 3. Cancel (Esc) │ -│ │ -│ ↑/↓ to navigate · enter to select · esc to cancel │ -╰───────────────────────────────────────────────────────────────────╯"; - assert_eq!(detect_github_copilot(content), AgentState::Blocked); - } - - #[test] - fn copilot_working_thinking_spinner() { - assert_eq!( - detect_github_copilot("○ Thinking esc cancel"), - AgentState::Working - ); - assert_eq!( - detect_github_copilot("◎ Thinking esc cancel"), - AgentState::Working - ); - assert_eq!( - detect_github_copilot("◉ Thinking esc cancel"), - AgentState::Working - ); - } - - // ---- Kimi ---- - - #[test] - fn kimi_blocked_approval_prompt_wins_over_spinner() { - let screen = "⠋ Using Shell (git log --oneline -10)\n╭─ approval ─╮\nShell is requesting approval to run command:\ngit log --oneline -10\n→ [1] Approve once\n[2] Approve for this session\n[3] Reject\n[4] Reject, tell the model what to do instead\n▲/▼ select 1/2/3/4 choose ↵ confirm"; - assert_eq!(detect_kimi(screen), AgentState::Blocked); - } - - #[test] - fn kimi_current_approval_panel_is_visible_blocker() { - let screen = "────────────────────────────────────────────────────\n ▶ Run this command?\n\n $ git status --short\n\n ▶ 1. Approve once\n 2. Approve for this session\n 3. Reject\n 4. Reject with feedback\n\n ↑/↓ select · 1/2/3/4 choose · ↵ confirm\n────────────────────────────────────────────────────"; - let detection = detect_agent(Some(Agent::Kimi), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn kimi_question_panel_is_visible_blocker() { - let screen = "────────────────────────────────────────────────────\n question\n\n (○) Destination Submit\n\n ? Which destination should I use?\n\n → [1] Local checkout\n [2] Remote branch\n [3] Other\n\n ↑↓ select 1-3 / ↵ choose ←/→/tab switch esc cancel\n────────────────────────────────────────────────────"; - let detection = detect_agent(Some(Agent::Kimi), screen); - - assert_eq!(detection.state, AgentState::Blocked); - assert!(detection.visible_blocker); - } - - #[test] - fn kimi_approval_words_without_prompt_stay_idle() { - assert_eq!(detect_kimi("approve?"), AgentState::Idle); - assert_eq!(detect_kimi("continue? [y/n]"), AgentState::Idle); - } - - #[test] - fn kimi_working_braille_thinking() { - assert_eq!( - detect_kimi("⠦ Thinking... <1s · 19 tokens"), - AgentState::Working - ); - } - - #[test] - fn kimi_working_braille_using_tool() { - assert_eq!( - detect_kimi("⠹ Using Shell (git log -20 --name-status)"), - AgentState::Working - ); - } - - #[test] - fn kimi_working_braille_working_status_is_visible_working() { - let detection = detect_agent(Some(Agent::Kimi), "⠋ working..."); - - assert_eq!(detection.state, AgentState::Working); - assert!(detection.visible_working); - } - - #[test] - fn kimi_working_moon_spinner() { - assert_eq!(detect_kimi("🌕"), AgentState::Working); - assert_eq!(detect_kimi("🌗"), AgentState::Working); - assert_eq!(detect_kimi("🌘"), AgentState::Working); - } - - #[test] - fn kimi_working_moon_spinner_above_input_box() { - let screen = "✨ yo\n\n🌗\n\n── input ─────────────────────────"; - assert_eq!(detect_kimi(screen), AgentState::Working); - } - - #[test] - fn kimi_old_transcript_words_stay_idle() { - assert_eq!(detect_kimi("thinking"), AgentState::Idle); - assert_eq!(detect_kimi("generating code"), AgentState::Idle); - assert_eq!( - detect_kimi("Used Shell (git log --oneline -10)"), - AgentState::Idle - ); - assert_eq!(detect_kimi("some 🌕 in prose"), AgentState::Idle); - } - - #[test] - fn kimi_idle() { - let screen = "Welcome to Kimi Code CLI!\n── input ─\n────────────────\nagent (Kimi-k2.6 ●) ~/Projects/herdr"; - assert_eq!(detect_kimi(screen), AgentState::Idle); - } - - #[test] - fn kimi_current_editor_box_does_not_set_screen_idle_signal() { - let screen = "╭──────────────────────────────────────────────────╮\n│ > │\n╰──────────────────────────────────────────────────╯\nk2 ~/Projects/herdr /help: show commands\n context: 0.0%"; - let detection = detect_agent(Some(Agent::Kimi), screen); - - assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_working); - assert!(!detection.visible_blocker); - } - - // ---- Kiro ---- - - #[test] - fn kiro_working_on_status_bar() { - let screen = "◕ Shell\n esc to cancel\n● 1 MCP failure — see /mcp\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6% ~\n\n Kiro is working · type to queue a message"; - assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Working); - } - - #[test] - fn kiro_working_on_tool_spinner_and_cancel_hint() { - let screen = "◕ Shell\n esc to cancel\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6%"; - assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Working); - } - - #[test] - fn kiro_idle_at_prompt() { - let screen = "● 1 MCP failure — see /mcp\n──────────────────────────────────────────────────────────────────────────────────────\nKiro · auto · ◔ 6% ~\n\n ask a question or describe a task ↵\n /copy to clipboard"; - assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Idle); - } - - #[test] - fn kiro_blocked_on_tool_approval_prompt() { - let screen = "↓ Shell mkdir -p /tmp/test-kiro-{a,b,c} && ls /tmp/test-kiro-*\n\n─────────────────────────────────────────────────────────────────────────────────────────\n shell requires approval\n ❯ Yes, single permission\n Trust, always allow in this session\n No (Tab to edit)\n─────────────────────────────────────────────────────────────────────────────────────────\n ESC to close | Tab to edit"; - assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Blocked); - } - - #[test] - fn kiro_blocked_on_subagent_tool_approval_prompt() { - let screen = " Please delegate to a sub-agent to search the web\n\n● Orchestrating (1 agent)\n esc to cancel\n ctrl+g open agent monitor\n ● web-research kiro_default ⚠ tool approval needed\n\n ◐ Tasks · 1 done · 2 remaining ctrl+x to expand\n──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n ⚠ 3 tool approvals pending from subagents\n ❯ (a) Approve all pending\n (f) Approve all pending and auto-approve all future requests\n (c) Configure individually (agent monitor)\n (x) Exit (cancel subagents) %"; - assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Blocked); - } - - #[test] - fn kiro_does_not_treat_stale_failure_spinner_as_working() { - let screen = "● 1 MCP failure — see /mcp\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6%\n\n ask a question or describe a task ↵"; - assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Idle); - } - - #[test] - fn kiro_identified_by_process_name() { - assert_eq!(identify_agent("kiro"), Some(Agent::Kiro)); - assert_eq!(identify_agent("kiro-cli"), Some(Agent::Kiro)); - } - - // ---- Droid ---- - - #[test] - fn droid_working_thinking_with_spinner() { - let screen = "> how u doin\n\n⠴ Thinking... (Press ESC to stop)\n\nAuto (Off)"; - assert_eq!(detect_droid(screen), AgentState::Working); - } - - #[test] - fn droid_working_esc_to_stop_alone() { - // ESC to stop without spinner is still working (UI chrome) - let screen = "Processing\n(Press ESC to stop)"; - assert_eq!(detect_droid(screen), AgentState::Working); - } - - #[test] - fn droid_waiting_execute_approval() { - let screen = concat!( - "⛬ I'll create some folders.\n\n", - " EXECUTE (mkdir -p /tmp/test, impact: medium)\n\n", - "╭────────────────────╮\n", - "│ > Yes, allow │\n", - "│ Yes, always allow │\n", - "│ No, cancel │\n", - "╰────────────────────╯\n", - " Use ↑↓ to navigate, Enter to select, Esc to cancel\n", - ); - assert_eq!(detect_droid(screen), AgentState::Blocked); - } - - #[test] - fn droid_waiting_selection_with_chrome() { - let screen = "│ > Yes, allow │\n│ No, cancel │\n Use ↑↓ to navigate, Enter to select, Esc to cancel"; - assert_eq!(detect_droid(screen), AgentState::Blocked); - } - - #[test] - fn droid_not_waiting_on_options_text_alone() { - // "Yes, allow" in normal conversation should NOT trigger blocked - let screen = "The user said > Yes, allow the changes"; - assert_eq!(detect_droid(screen), AgentState::Idle); - } - - #[test] - fn droid_idle_prompt() { - let screen = - "╭──────────────────╮\n│ > Try something │\n╰──────────────────╯\n? for help"; - assert_eq!(detect_droid(screen), AgentState::Idle); - } - - #[test] - fn droid_idle_after_response() { - let screen = - "⛬ Doing well, thanks!\n\nAuto (Off)\n╭──────────╮\n│ > │\n╰──────────╯"; - assert_eq!(detect_droid(screen), AgentState::Idle); - } - - #[test] - fn droid_braille_spinner_detected() { - assert!(has_braille_spinner("⠴ Thinking...")); - assert!(has_braille_spinner(" ⠧ Loading...")); - assert!(has_braille_spinner("text\n⠋ Working\nmore")); - } - - #[test] - fn droid_braille_spinner_no_false_positive() { - assert!(!has_braille_spinner("normal text")); - assert!(!has_braille_spinner("Thinking...")); - assert!(!has_braille_spinner("some ⠴ in middle of text")); - } - - #[test] - fn droid_identified_by_process_name() { - assert_eq!(identify_agent("droid"), Some(Agent::Droid)); - } - - // ---- Amp ---- - - #[test] - fn amp_blocked_waiting_for_approval() { - let screen = "Invoke tool shell_command?\n▸● Approve [Alt+1]\n ○ Allow All for This Session [Alt+2]\n ○ Allow All for Every Session [Alt+3]\n ○ Deny with feedback [Alt+4]\nWaiting for approval..."; - assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked); - } - - #[test] - fn amp_blocked_run_this_command() { - let screen = "Run this command?\nrg --files\n▸● Approve [Alt+1]\n ○ Allow All for This Session [Alt+2]\n ○ Allow All for Every Session [Alt+3]\n ○ Deny with feedback [Alt+4]"; - assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked); - } - - #[test] - fn amp_blocked_allow_editing_file() { - let screen = "Allow editing file:\nsrc/detect.rs\n▸● Approve [Alt+1]\n ○ Allow File for Every Session [Alt+2]\n ○ Allow All for This Session [Alt+3]\n ○ Deny with feedback [Alt+4]"; - assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked); - } - - #[test] - fn amp_blocked_allow_creating_file() { - let screen = "Allow creating file:\nsrc/new_file.rs\n▸● Approve [Alt+1]\n ○ Allow File for Every Session [Alt+2]\n ○ Allow All for This Session [Alt+3]\n ○ Deny with feedback [Alt+4]"; - assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked); - } - - #[test] - fn amp_working_running_tools() { - let screen = " ✓ Search Map the core runtime architecture\n ⋯ Oracle ▼\n ≈ Running tools... Esc to cancel"; - assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Working); - } - - #[test] - fn amp_idle() { - let screen = " Response complete.\n\n╭─100% of 272k · $1.20─────────────────────────╮\n│ │\n╰───────────────────────~/Projects/herdr (master)╯"; - assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Idle); - } - - #[test] - fn amp_identified_by_process_name() { - assert_eq!(identify_agent("amp"), Some(Agent::Amp)); - assert_eq!(identify_agent("amp-local"), Some(Agent::Amp)); - } - - // ---- Grok ---- - - #[test] - fn grok_blocked_on_permission_prompt() { - let screen = "Show recent commit history for analysis\n\ - git -C /home/can/Projects/herdr log --oneline --decorate -n 12\n\ - Use ← → to choose permission whitelist scope\n\n\ - 1 (○) Always allow: git -C\n\ - 2 (●) Yes, proceed\n\ - 3 (○) No, reject (type to add feedback)\n\n\ - 1/3:select │ ←/→:scope │ Ctrl+o:yolo │ Ctrl+c:cancel"; - assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked); - } - - #[test] - fn grok_blocked_wins_over_spinner() { - let screen = "⠹ Run git 30s\nUse ← → to choose permission whitelist scope\nYes, proceed\nNo, reject (type to add feedback)"; - assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked); - } - - #[test] - fn grok_choice_words_without_scope_selector_are_not_blocked() { - let screen = "Yes, proceed\nNo, reject (type to add feedback)"; - assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Idle); - } - - #[test] - fn grok_working_on_waiting_spinner() { - let screen = "⠋ Waiting… 1.8s\nCtrl+c:cancel │ Ctrl+Enter:interject"; - assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working); - } - - #[test] - fn grok_working_on_tool_spinner() { - let screen = "⠼ Run git -C /home/can/Projects/herdr log --oneline 1.0s"; - assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working); - } - - #[test] - fn grok_idle_after_turn_completed() { - let screen = "yo\n\nTurn completed in 1.7s.\n\n╭────╮\n│ ❯ │\n╰─ gpt-5.4 ─╯"; - assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Idle); - } - - // ---- Hermes ---- - - #[test] - fn hermes_identified_by_process_name() { - assert_eq!(identify_agent("hermes"), Some(Agent::Hermes)); - assert_eq!(identify_agent("hermes-agent"), Some(Agent::Hermes)); - } - - #[test] - fn hermes_working_on_interrupt_footer() { - let screen = " (⌐■_■) computing...\n\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 2m │ ⏱ 3s\n─────────────────────────────────────────────────────────────────────────────────────────\n⚕ ❯ msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel"; - assert_eq!( - detect_state(Some(Agent::Hermes), screen), - AgentState::Working - ); - } - - #[test] - fn hermes_idle_ignores_stale_initializing_agent_text() { - let screen = "● say exactly READY and stop\nInitializing agent...\n\n╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n READY\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 15s │ ⏲ 2s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯"; - assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle); - } - - #[test] - fn hermes_blocked_on_dangerous_command_prompt() { - let screen = "╭────────────────────────────────────────────────────────────╮\n│ ⚠️ Dangerous Command │\n│ mkdir -p /tmp/herdr-hermes-block-test/subdir && touch │\n│ ❯ 1. Allow once │\n│ 2. Allow for this session │\n│ 3. Add to permanent allowlist │\n│ 4. Deny │\n│ 5. Show full command │\n╰────────────────────────────────────────────────────────────╯\n ↑/↓ to select, Enter to confirm\n⚠ ❯"; - assert_eq!( - detect_state(Some(Agent::Hermes), screen), - AgentState::Blocked - ); - } - - #[test] - fn hermes_idle_at_prompt_after_response() { - let screen = "╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n READY\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 15s │ ⏲ 2s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯\n─────────────────────────────────────────────────────────────────────────────────────────"; - assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle); - } - - #[test] - fn hermes_denied_message_is_idle() { - let screen = "╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n Command was blocked/denied by the safety layer. I did not retry.\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.4K/272K │ [█░░░░░░░░░] 6% │ 2m │ ⏲ 11s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯"; - assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle); - } - - // ---- Qodercli ---- - - #[test] - fn qodercli_identified_by_process_name() { - assert_eq!(identify_agent("qodercli"), Some(Agent::Qodercli)); - assert_eq!(identify_agent("qoderclicn"), Some(Agent::Qodercli)); - assert_eq!(identify_agent("qoder"), Some(Agent::Qodercli)); - assert_eq!(identify_agent("qodercn"), Some(Agent::Qodercli)); - } - - #[test] - fn qodercli_blocked_on_confirmation() { - assert_eq!( - detect_qodercli("Waiting for user confirmation...\nAllow\nReject"), - AgentState::Blocked, - ); - } - - #[test] - fn qodercli_waiting_confirmation_without_choices_is_idle() { - assert_eq!( - detect_qodercli("Waiting for user confirmation..."), - AgentState::Idle, - ); - } - - #[test] - fn qodercli_working_on_spinner() { - assert_eq!(detect_qodercli("\u{280B} Thinking..."), AgentState::Working); - } - - #[test] - fn qodercli_idle_on_prompt() { - assert_eq!(detect_qodercli("> "), AgentState::Idle); - } - - #[test] - fn qodercli_idle_when_only_stale_braille_glyph_in_scrollback() { - // A single stray braille character in a previous output line must not - // flip the pane to Working — only an actual spinner *row* should. - let screen = "\ -agent finished a previous task.\n\ -\u{280B}\n\ -> \n"; - assert_eq!(detect_qodercli(screen), AgentState::Idle); - } - - #[test] - fn qodercli_working_on_full_spinner_row() { - // Real spinner row: braille glyph + space + alphabetic phrase. - let screen = "\u{280B} Thinking...\n"; - assert_eq!(detect_qodercli(screen), AgentState::Working); - } - - #[test] - fn qodercli_working_on_esc_to_cancel_hint() { - // The "(esc to cancel, …)" suffix is qodercli's explicit working - // marker. It must trigger Working even if a hook icon replaced the - // spinner glyph in this frame. - let screen = "Thinking... (esc to cancel, 5s)\n"; - assert_eq!(detect_qodercli(screen), AgentState::Working); - } - - #[test] - fn qodercli_idle_when_text_mentions_working_in_prose() { - // The previous heuristic treated the bare word "working" as Working, - // which produced false positives for narrative output (commits, logs, - // Markdown). The pane should remain Idle until a real working signal - // appears. - let screen = "\ -fix: keep working set warm across reloads\n\ -\n\ -> \n"; - assert_eq!(detect_qodercli(screen), AgentState::Idle); - } - - #[test] - fn qodercli_exit_hint_is_not_visible_blocker() { - let screen = "\ -\u{280B} Thinking...\n\ -Press Ctrl+C again to exit.\n"; - let detection = detect_agent(Some(Agent::Qodercli), screen); - - assert!(!detection.visible_blocker); - } - - #[test] - fn qodercli_esc_rewind_is_not_visible_blocker() { - let screen = "Press Esc again to rewind.\n"; - let detection = detect_agent(Some(Agent::Qodercli), screen); - - assert!(!detection.visible_blocker); - } - - #[test] - fn qodercli_blocked_on_permission_required_dialog() { - // qodercli renders this dialog when a tool call needs user approval. - let screen = "\ -Permission Required\n\ -Caller: test\n\ -Command: mkdir -p /root/example\n\ -Allow once or always?\n\ - \u{276F} 1. Allow Once - allow `mkdir` for one\n\ - 2. Always allow `mkdir` for future sessions\n\ - 3. Reject and tell qodercli something\n"; - assert_eq!(detect_qodercli(screen), AgentState::Blocked); - } - - #[test] - fn qodercli_blocked_on_permission_required_alone() { - // Even when the prompt copy gets truncated by the viewport, the title - // alone should be enough to flip the pane to blocked. - assert_eq!(detect_qodercli("Permission Required"), AgentState::Blocked,); - } - - #[test] - fn qodercli_blocked_on_askuser_enter_response_placeholder() { - // qodercli's ask-user tool renders an input box with this placeholder - // when waiting for the user to type a response. - let screen = "\ -What kind of project are you working on?\n\ -> \n\ - Enter your response\n"; - assert_eq!(detect_qodercli(screen), AgentState::Blocked); - } - - #[test] - fn qodercli_blocked_on_askuser_review_tab() { - // The multi-question/multi-select review tab heading is unique to the - // ask-user dialog and means the agent is parked waiting on user input. - let screen = "\ -Review your answers:\n\ -\n\ -Project type \u{2192} Web app\n\ -Stack \u{2192} (not answered)\n"; - assert_eq!(detect_qodercli(screen), AgentState::Blocked); - } - - #[test] - fn qodercli_blocked_on_interactive_shell_waiting() { - // When qodercli spawns an interactive shell, the loading row turns - // into a "Shell awaiting input" hint until the user takes focus. - let screen = "! Shell awaiting input (Tab to focus)\n"; - assert_eq!(detect_qodercli(screen), AgentState::Blocked); - } - - #[test] - fn qodercli_blocked_on_askuser_single_choice_dialog() { - // Single-select ask-user has no "Enter your response" placeholder and - // no "Review your answers:" heading. The BaseTabDialog title - // "Asking User" is the only stable signal across every ask-user form. - let screen = "\ -Asking User\n\ -\n\ -Which framework should we use?\n\ - React\n\ - Vue\n\ - Svelte\n"; - assert_eq!(detect_qodercli(screen), AgentState::Blocked); - } - - // ---- Helpers ---- - - #[test] - fn content_above_prompt_box_extracts_correctly() { - let screen = "line1\nline2\n──────\n❯ \n──────"; - let above = content_above_prompt_box(screen); - assert!(above.contains("line1")); - assert!(above.contains("line2")); - assert!(!above.contains('❯')); - } - - #[test] - fn content_above_prompt_box_no_box() { - let screen = "just some text\nno borders here"; - let above = content_above_prompt_box(screen); - assert_eq!(above, screen); - } - - #[test] - fn spinner_activity_detected() { - assert!(has_spinner_activity("· Thinking…")); - assert!(has_spinner_activity("✽ Tempering…")); - assert!(has_spinner_activity("✳ Simplifying recompute_tangents…")); - assert!(has_spinner_activity(" ✶ Reading…")); // with leading whitespace - assert!(has_spinner_activity("✻ Pouncing…")); - assert!(has_spinner_activity("✽ Processing…")); - } - - #[test] - fn spinner_activity_not_false_positive() { - assert!(!has_spinner_activity("normal text")); - assert!(!has_spinner_activity("✽ no ellipsis here")); - assert!(!has_spinner_activity("✽ …")); - assert!(!has_spinner_activity("some ✽ in the middle")); - } - - #[test] - fn cursor_spinner_detected() { - assert!(has_cursor_spinner("⬡ Grepping..")); - assert!(has_cursor_spinner("⬢ Reading…")); - assert!(has_cursor_spinner("⠠⠜ Running 5.52k tokens")); - assert!(has_cursor_spinner("⠞ Working 5.62k tokens")); - assert!(has_cursor_spinner("⠛ Grepping 1.2k tokens")); - assert!(has_cursor_spinner("⠛ Analyzing 1.2k tokens")); - } - - #[test] - fn cursor_spinner_not_false_positive() { - assert!(!has_cursor_spinner("normal text")); - assert!(!has_cursor_spinner("some ⬡ in middle")); - assert!(!has_cursor_spinner("⠛ Read notes")); - } - // ---- Process identification (real PTY) ---- #[cfg(target_os = "linux")] @@ -3043,20 +1041,4 @@ Which framework should we use?\n\ // In CI/test environments without a terminal, tpgid is typically -1 let _ = tpgid; } - - #[test] - fn terminal_tail_content_works_with_detection() { - assert_eq!(detect_pi("Working..."), AgentState::Working); - } - - #[test] - fn ansi_colored_content_still_detects_working() { - assert_eq!(detect_pi("\x1b[31mWorking...\x1b[0m"), AgentState::Working); - } - - #[test] - fn visible_claude_prompt_box_is_idle() { - let screen = "Task complete.\n─────────────\n❯ \n─────────────"; - assert_eq!(detect_claude(screen), AgentState::Idle); - } } diff --git a/src/events.rs b/src/events.rs index cf6a208e..07660c71 100644 --- a/src/events.rs +++ b/src/events.rs @@ -92,6 +92,11 @@ pub enum AppEvent { version: String, install_command: String, }, + /// Remote agent detection manifest update check finished. + AgentDetectionManifestsUpdated { + updated: Vec, + 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 }, diff --git a/src/main.rs b/src/main.rs index d2a1767b..d2beb114 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,7 +42,6 @@ fn pop_keyboard_enhancement_flags() -> io::Result<()> { Ok(()) } -mod agent_detection_policy; mod agent_resume; mod api; mod app; diff --git a/src/pane.rs b/src/pane.rs index 1dd641f4..b880ff32 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -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, 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, pty_output_seq: Arc, input_write_seq: Arc, + full_lifecycle_authority_active: Arc, state_events: mpsc::Sender, ) -> ( 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>, kitty_keyboard_flags: Arc, input_write_seq: Arc, + full_lifecycle_authority_active: Arc, detect_reset_notify: Arc, pending_release: Arc>>, 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 { + 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, diff --git a/src/pane/agent_detection.rs b/src/pane/agent_detection.rs index 53e2ea31..e6afd368 100644 --- a/src/pane/agent_detection.rs +++ b/src/pane/agent_detection.rs @@ -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, + _pty_signal: Option, 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, - now: std::time::Instant, + _previous: DetectionPublishState, + _next: DetectionPublishState, + _agent_changed: bool, + _process_exited: bool, + _pty_signal: Option, + _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, } -fn agent_activity_veto_requires_screen(agent: Option) -> 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, - 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, +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, 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, +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, @@ -535,118 +434,40 @@ pub(super) struct ScreenDetectionPublishInput<'a> { pub(super) process_exited: bool, pub(super) agent_changed: bool, pub(super) pty_activity: Option, - pub(super) content: &'a str, pub(super) now: std::time::Instant, } -#[derive(Debug, Clone, Copy)] -pub(super) struct PtyWorkingPublishInput { - pub(super) agent: Option, - pub(super) current_state: AgentState, - pub(super) last_visible_blocker: bool, - pub(super) last_visible_working: bool, - pub(super) last_visible_signal_refresh: Option, - pub(super) pty_activity: Option, - 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 { - 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, + _pty_signal: Option, 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, 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, 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(); diff --git a/src/server/headless.rs b/src/server/headless.rs index 806cce4b..faf43c10 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -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(); diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 5d1ce1d7..727e9929 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -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 { + 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) } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 74b7973d..25a48e5f 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -31,6 +31,7 @@ pub struct HookAuthority { struct SuppressedFullLifecycleHookReport { agent_label: String, session_ref: Option, + observed_at: Instant, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -173,7 +174,7 @@ impl TerminalState { agent: Option, 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, ) -> 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, + 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(); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index b7104ea6..d17ba4ed 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -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( diff --git a/tests/api_ping.rs b/tests/api_ping.rs index fa52c5f0..df0b5908 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -253,6 +253,37 @@ where } } +#[cfg(not(target_os = "macos"))] +fn wait_for_events( + reader: &mut JsonLineReader, + expected: &[&str], + timeout: Duration, +) -> Vec { + 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(