aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-19 16:02:57 +0300
committerIgor Soarez <igor@soarez.org>2026-10-19 16:02:57 +0300
commitab33633ceb9e4bea3e292b2371241797d1587d02 (patch)
tree7b03faf43e5714782e43d582a00626a6f0d852f1
initial dingger: tmux agent watcher with herdr's detection engine
extract detect-core from herdr's src/detect (engine + 22 manifests, tests unchanged) and add the dingger binary: tmux capture-pane watcher, process-tree agent identification, herdr's notification policy, embedded notification sounds, @agent_state write-back, and catalog-based manifest refresh. viewer screens are non-observations per the engine contract; reviewed by a three-model panel (opus 5.5, gpt 6.1 sol, kimi k3) with all confirmed findings applied as deletions.
-rw-r--r--.gitignore1
-rw-r--r--Cargo.lock725
-rw-r--r--Cargo.toml14
-rw-r--r--README.md61
-rw-r--r--crates/detect-core/Cargo.toml11
-rw-r--r--crates/detect-core/src/lib.rs277
-rw-r--r--crates/detect-core/src/manifest.rs1546
-rw-r--r--crates/detect-core/src/manifest/tests.rs830
-rw-r--r--crates/detect-core/src/manifest_update.rs156
-rw-r--r--crates/detect-core/src/manifests/amp.toml65
-rw-r--r--crates/detect-core/src/manifests/antigravity.toml80
-rw-r--r--crates/detect-core/src/manifests/claude.toml230
-rw-r--r--crates/detect-core/src/manifests/cline.toml63
-rw-r--r--crates/detect-core/src/manifests/codex.toml129
-rw-r--r--crates/detect-core/src/manifests/cursor.toml57
-rw-r--r--crates/detect-core/src/manifests/devin.toml86
-rw-r--r--crates/detect-core/src/manifests/droid.toml48
-rw-r--r--crates/detect-core/src/manifests/gemini.toml25
-rw-r--r--crates/detect-core/src/manifests/github-copilot.toml45
-rw-r--r--crates/detect-core/src/manifests/grok.toml161
-rw-r--r--crates/detect-core/src/manifests/hermes.toml102
-rw-r--r--crates/detect-core/src/manifests/kilo.toml24
-rw-r--r--crates/detect-core/src/manifests/kimi.toml77
-rw-r--r--crates/detect-core/src/manifests/kiro.toml97
-rw-r--r--crates/detect-core/src/manifests/letta.toml95
-rw-r--r--crates/detect-core/src/manifests/maki.toml68
-rw-r--r--crates/detect-core/src/manifests/muse.toml113
-rw-r--r--crates/detect-core/src/manifests/opencode.toml37
-rw-r--r--crates/detect-core/src/manifests/pi.toml27
-rw-r--r--crates/detect-core/src/manifests/qodercli.toml38
-rw-r--r--crates/detect-core/src/manifests/qwen.toml119
-rw-r--r--crates/dinggerd/Cargo.toml18
-rw-r--r--crates/dinggerd/assets/done.mp3bin0 -> 34560 bytes
-rw-r--r--crates/dinggerd/assets/request.mp3bin0 -> 46811 bytes
-rw-r--r--crates/dinggerd/src/main.rs100
-rw-r--r--crates/dinggerd/src/manifest_update.rs79
-rw-r--r--crates/dinggerd/src/notify.rs76
-rw-r--r--crates/dinggerd/src/tmux.rs177
-rw-r--r--crates/dinggerd/src/watcher.rs156
39 files changed, 6013 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..8569380
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,725 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "cc"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "clap"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
+dependencies = [
+ "anstyle",
+ "clap_lex",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
+
+[[package]]
+name = "deranged"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
+
+[[package]]
+name = "detect-core"
+version = "0.1.0"
+dependencies = [
+ "regex",
+ "serde",
+ "serde_json",
+ "toml",
+]
+
+[[package]]
+name = "dinggerd"
+version = "0.1.0"
+dependencies = [
+ "clap",
+ "detect-core",
+ "mac-notification-sys",
+ "serde",
+ "toml",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags",
+ "objc2",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
+
+[[package]]
+name = "libc"
+version = "0.2.191"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "mac-notification-sys"
+version = "0.6.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
+dependencies = [
+ "cc",
+ "log",
+ "objc2",
+ "objc2-foundation",
+ "time",
+ "uuid",
+]
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "objc2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags",
+ "dispatch2",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags",
+ "block2",
+ "libc",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "powerfmt"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.154"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+
+[[package]]
+name = "uuid"
+version = "1.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
+dependencies = [
+ "getrandom",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..b565ce2
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,14 @@
+[workspace]
+members = ["crates/detect-core", "crates/dinggerd"]
+resolver = "2"
+
+[workspace.package]
+edition = "2021"
+
+[workspace.dependencies]
+regex = "1"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+toml = "0.8"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f47f1d5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,61 @@
+# dingger
+
+macOS agent watcher for tmux panes. dingger watches every pane in your tmux
+server, detects which AI coding agent is running in each one (claude, codex,
+pi, gemini, and 19 others), tracks its state (idle / working / blocked), and
+sends a macOS notification — with sound — when an agent needs attention or
+finishes.
+
+dingger owns no terminals. It is a client of your tmux server: `capture-pane`
+for screen text, `#{pane_title}` for OSC titles, and `#{pane_pid}` plus a `ps`
+tree walk for agent identity. The detection engine and rule manifests are
+extracted from [herdr](https://github.com/herdrdev/herdr) (Apache-2.0), so
+detection quality matches herdr's screen-matching engine. herdr's manifests
+use the same terminal-screen regions (`capture-pane` output maps 1:1); OSC
+progress sequences are not re-exposed by tmux (a small confidence loss on a
+few rules). Agents launched as bare `node <entry.js>` scripts (rather than via
+an install shim) are not identified yet.
+
+## Usage
+
+```bash
+dingger watch # run the watcher; notify on transitions
+dingger status # table of panes, agents, states
+dingger manifests update # refresh manifests from herdr's published catalog
+```
+
+`dingger status` reads state written back into tmux as the per-pane user
+option `@agent_state`, so it works without any daemon IPC. Render it directly
+in your status line with `#{:@agent_state}` in `status-format` /
+`pane-border-status`. `dingger manifests update` stores refreshed manifests
+from herdr's published catalog; restart `dingger watch` to pick them up.
+
+Environment:
+
+- `DINGGER_TMUX_SOCKET` — target a named tmux server socket (`tmux -L <name>`)
+- `DINGGER_CONFIG_DIR` / `DINGGER_STATE_DIR` — override/remote manifest locations
+ (default `~/.config/dingger` / `~/.local/state/dingger`)
+
+## Notification policy
+
+herdr's transition policy: notify on any transition to `blocked` ("needs
+attention", `request.mp3`) and on `working`/`blocked` → `idle` ("finished",
+`done.mp3`), with no notification for `unknown` panes. One deliberate
+difference from herdr: a pane on the active window of an attached client is
+suppressed entirely (herdr still plays the blocker sound there). Viewer
+screens such as Claude's transcript never change state.
+
+## Layout
+
+- `crates/detect-core` — the detection engine (manifest compiler, region
+ extractors, priority arbitration) and 22 bundled manifests, extracted from
+ herdr's `src/detect/`. Pure text-in → state-out.
+- `crates/dinggerd` — the `dingger` binary: tmux watcher, notification
+ policy, sounds, CLI.
+- `sounds/` — herdr's bundled notification sounds.
+
+## Status
+
+Early scaffold. Roadmap: menu bar UI (Swift shell over the daemon), tmux
+control-mode event source instead of polling, configurable notification
+policy.
diff --git a/crates/detect-core/Cargo.toml b/crates/detect-core/Cargo.toml
new file mode 100644
index 0000000..0e2bcc8
--- /dev/null
+++ b/crates/detect-core/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "detect-core"
+version = "0.1.0"
+edition = "2021"
+description = "Agent state detection engine extracted from herdr (Apache-2.0, Copyright herdr contributors)"
+
+[dependencies]
+regex = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+toml = { workspace = true }
diff --git a/crates/detect-core/src/lib.rs b/crates/detect-core/src/lib.rs
new file mode 100644
index 0000000..ef6e964
--- /dev/null
+++ b/crates/detect-core/src/lib.rs
@@ -0,0 +1,277 @@
+//! Agent state detection engine, extracted from herdr's `src/detect/`
+//! (Apache-2.0, Copyright herdr contributors).
+//!
+//! The engine is a pure function over a terminal screen snapshot plus OSC
+//! title/progress strings: `detect_agent_with_osc(agent, screen, title,
+//! progress) -> AgentDetection`. Which agent a pane is running is decided by
+//! the caller; `identify_agent` maps a process name to an [`Agent`].
+//!
+//! Detection rules live in declarative TOML manifests (`manifests/`) compiled
+//! into the binary. Locally refreshed manifests (stored by `dingger manifests
+//! update`) and user overrides under `DINGGER_CONFIG_DIR` shadow the bundled
+//! set.
+
+pub mod manifest;
+pub mod manifest_update;
+
+/// The engine's explain/debug surface lives in `manifest` (DetectionExplain,
+/// explain_with_input, agent_state_label); dinggerd itself consumes only
+/// `detect_agent_with_osc`, labels, and the manifest paths.
+/// The detected state of a terminal pane.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub enum AgentState {
+ /// Agent finished, prompt visible, nothing happening.
+ Idle,
+ /// Agent is actively working/processing.
+ Working,
+ /// Agent needs human input and is blocked on a response.
+ Blocked,
+ /// Plain shell or unrecognized program.
+ Unknown,
+}
+
+/// Screen-derived agent state plus confidence metadata used for source arbitration.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct AgentDetection {
+ pub state: AgentState,
+ /// True when the current screen is an agent-owned viewer that shows
+ /// transcript/history instead of the live prompt state. Callers must treat
+ /// such a screen as a non-observation: leave the previously published
+ /// state standing instead of applying [`Self::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.
+ pub visible_blocker: bool,
+ /// True when the current screen visibly shows live working chrome. In
+ /// herdr PTY activity is the normal working authority; dingger is
+ /// screen-only and consumes [`Self::state`] and [`Self::skip_state_update`]
+ /// only.
+ pub visible_working: bool,
+}
+
+/// Which agent we detected running in a pane.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Agent {
+ Pi,
+ Claude,
+ Codex,
+ Gemini,
+ Cursor,
+ Devin,
+ Antigravity,
+ Cline,
+ Omp,
+ Mastracode,
+ OpenCode,
+ GithubCopilot,
+ Kimi,
+ Kiro,
+ Droid,
+ Amp,
+ Grok,
+ Hermes,
+ Kilo,
+ Qodercli,
+ Qwen,
+ Letta,
+ Maki,
+ Muse,
+}
+
+impl Agent {
+ pub const ALL: [Self; 24] = [
+ Self::Pi,
+ Self::Claude,
+ Self::Codex,
+ Self::Gemini,
+ Self::Cursor,
+ Self::Devin,
+ Self::Antigravity,
+ Self::Cline,
+ Self::Omp,
+ Self::Mastracode,
+ Self::OpenCode,
+ Self::GithubCopilot,
+ Self::Kimi,
+ Self::Kiro,
+ Self::Droid,
+ Self::Amp,
+ Self::Grok,
+ Self::Hermes,
+ Self::Kilo,
+ Self::Qodercli,
+ Self::Qwen,
+ Self::Letta,
+ Self::Maki,
+ Self::Muse,
+ ];
+
+ pub const SCREEN_MANIFEST_AGENTS: [Self; 22] = [
+ Self::Pi,
+ Self::Claude,
+ Self::Codex,
+ Self::Gemini,
+ Self::Cursor,
+ Self::Devin,
+ Self::Antigravity,
+ Self::Cline,
+ Self::OpenCode,
+ Self::GithubCopilot,
+ Self::Kimi,
+ Self::Kiro,
+ Self::Droid,
+ Self::Amp,
+ Self::Grok,
+ Self::Hermes,
+ Self::Kilo,
+ Self::Qodercli,
+ Self::Qwen,
+ Self::Letta,
+ Self::Maki,
+ Self::Muse,
+ ];
+}
+
+pub fn agent_label(agent: Agent) -> &'static str {
+ match agent {
+ Agent::Pi => "pi",
+ Agent::Claude => "claude",
+ Agent::Codex => "codex",
+ Agent::Gemini => "gemini",
+ Agent::Cursor => "cursor",
+ Agent::Devin => "devin",
+ Agent::Antigravity => "agy",
+ Agent::Cline => "cline",
+ Agent::Omp => "omp",
+ Agent::Mastracode => "mastracode",
+ Agent::OpenCode => "opencode",
+ Agent::GithubCopilot => "copilot",
+ Agent::Kimi => "kimi",
+ Agent::Kiro => "kiro",
+ Agent::Droid => "droid",
+ Agent::Amp => "amp",
+ Agent::Grok => "grok",
+ Agent::Hermes => "hermes",
+ Agent::Kilo => "kilo",
+ Agent::Qodercli => "qodercli",
+ Agent::Qwen => "qwen",
+ Agent::Letta => "letta",
+ Agent::Maki => "maki",
+ Agent::Muse => "muse",
+ }
+}
+
+pub fn parse_agent_label(agent: &str) -> Option<Agent> {
+ let name = normalized_agent_lookup_name(agent);
+ parse_canonical_agent_label(&name).or_else(|| lookup_agent(&name))
+}
+
+pub(crate) fn parse_canonical_agent_label(label: &str) -> Option<Agent> {
+ let agent = lookup_agent(label)?;
+ (agent_label(agent) == label).then_some(agent)
+}
+
+fn lookup_agent(name: &str) -> Option<Agent> {
+ let name = path_basename(name);
+ match name {
+ "pi" => Some(Agent::Pi),
+ "claude" | "claude-code" => Some(Agent::Claude),
+ "codex" => Some(Agent::Codex),
+ "gemini" => Some(Agent::Gemini),
+ "cursor" | "cursor-agent" => Some(Agent::Cursor),
+ "devin" | "devin-cli" | "devin cli" => Some(Agent::Devin),
+ "agy" | "antigravity" | "antigravity-cli" => Some(Agent::Antigravity),
+ "cline" | ".cline" => Some(Agent::Cline),
+ "omp" => Some(Agent::Omp),
+ "mastracode" | "mastra-code" | "mastra code" => Some(Agent::Mastracode),
+ "opencode" | "opencode2" | "open-code" => Some(Agent::OpenCode),
+ "copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot),
+ "kimi" | "kimi-code" | "kimi code" => Some(Agent::Kimi),
+ "kiro" | "kiro-cli" => Some(Agent::Kiro),
+ "droid" => Some(Agent::Droid),
+ "amp" | "amp-local" => Some(Agent::Amp),
+ "grok" | "grok-build" => Some(Agent::Grok),
+ "hermes" | "hermes-agent" => Some(Agent::Hermes),
+ "kilo" | "kilo-code" | "kilo code" => Some(Agent::Kilo),
+ "qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli),
+ "qwen" | "qwen-code" | "qwen code" => Some(Agent::Qwen),
+ "letta" | "letta-code" | "letta code" => Some(Agent::Letta),
+ "maki" => Some(Agent::Maki),
+ "muse" | "muse-code" | "muse-cli" => Some(Agent::Muse),
+ _ if is_muse_versioned_binary(name) => Some(Agent::Muse),
+ _ => None,
+ }
+}
+
+/// Muse's install-dir launcher script resolves the active release and execs
+/// `muse-bin-<version>` (e.g. `muse-bin-0.1.0-R708.1`), so the running
+/// process never carries a bare `muse`/`muse-bin` alias. Require a digit
+/// immediately after the `muse-bin-` prefix so unrelated binaries such as
+/// `muse-binary` or a bare `muse-bin` stay unmatched.
+/// Accepts path-qualified `argv0` values by checking only the basename, since
+/// the launcher may `exec` with an absolute install-dir path.
+fn is_muse_versioned_binary(name: &str) -> bool {
+ path_basename(name)
+ .strip_prefix("muse-bin-")
+ .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
+}
+
+/// Identify which agent is running from the process name.
+/// Returns `None` for plain shells or unrecognized programs.
+pub fn identify_agent(process_name: &str) -> Option<Agent> {
+ parse_agent_label(process_name)
+}
+
+pub(crate) fn normalized_agent_lookup_name(name: &str) -> String {
+ let mut name = name.trim().to_lowercase();
+ for suffix in [".exe", ".cmd", ".bat", ".ps1", ".js"] {
+ if name.ends_with(suffix) {
+ name.truncate(name.len() - suffix.len());
+ break;
+ }
+ }
+ name
+}
+
+pub(crate) fn path_basename(path: &str) -> &str {
+ path.rsplit(['/', '\\'])
+ .find(|component| !component.is_empty())
+ .unwrap_or(path)
+}
+
+/// Serialize tests that mutate the config/state env vars.
+#[cfg(test)]
+pub fn test_env_lock() -> &'static std::sync::Mutex<()> {
+ static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
+ LOCK.get_or_init(|| std::sync::Mutex::new(()))
+}
+
+/// Detect state using screen content plus OSC title/progress strings.
+/// If `agent` is `None`, returns `Unknown`.
+pub fn detect_agent_with_osc(
+ agent: Option<Agent>,
+ screen_content: &str,
+ osc_title: &str,
+ osc_progress: &str,
+) -> AgentDetection {
+ let Some(agent) = agent else {
+ return AgentDetection {
+ state: AgentState::Unknown,
+ skip_state_update: false,
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ };
+ manifest::detect_with_osc(
+ agent,
+ manifest::DetectionInput {
+ screen: screen_content,
+ osc_title,
+ osc_progress,
+ },
+ )
+}
diff --git a/crates/detect-core/src/manifest.rs b/crates/detect-core/src/manifest.rs
new file mode 100644
index 0000000..dba7520
--- /dev/null
+++ b/crates/detect-core/src/manifest.rs
@@ -0,0 +1,1546 @@
+use std::{
+ path::{Path, PathBuf},
+ sync::{Arc, 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";
+
+/// Input to the detection engine, carrying the screen snapshot plus any
+/// OSC-derived strings captured from the terminal title / progress sequences.
+/// Pass empty strings for `osc_title` and `osc_progress` when the data is not
+/// available — behavior is identical to the pre-OSC engine in that case.
+#[derive(Debug, Clone, Copy)]
+pub struct DetectionInput<'a> {
+ pub screen: &'a str,
+ pub osc_title: &'a str,
+ pub osc_progress: &'a str,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DetectionExplain {
+ pub agent: Option<String>,
+ pub state: AgentState,
+ pub source: Option<ManifestSource>,
+ pub matched_rule: Option<MatchedRule>,
+ 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<String>,
+ pub fallback_reason: Option<String>,
+ pub evaluated_rules: Vec<EvaluatedRule>,
+ pub warning: Option<String>,
+ pub manifest_version: Option<String>,
+ pub cached_remote_version: Option<String>,
+ pub local_override_shadowing_remote: bool,
+ pub remote_update_status: Option<String>,
+ pub remote_update_error: Option<String>,
+}
+
+#[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 struct AgentManifestSummary {
+ pub agent: Agent,
+ pub active_source: ManifestSource,
+ pub active_version: Option<String>,
+ pub cached_remote_version: Option<String>,
+ pub local_override_shadowing_remote: bool,
+ pub warning: Option<String>,
+}
+
+pub fn manifest_summaries() -> Vec<AgentManifestSummary> {
+ 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<String>,
+ pub regex: Vec<String>,
+ pub line_regex: Vec<String>,
+ 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,
+ // Keep Regex search caches warm across manifest loads and pane polling.
+ compiled_rules: Arc<[CompiledRule]>,
+ source: ManifestSource,
+ warning: Option<String>,
+ cached_remote_version: Option<String>,
+ local_override_shadowing_remote: bool,
+}
+
+#[derive(Debug, Clone)]
+struct ManifestCache {
+ manifests: Vec<(Agent, Option<LoadedManifest>)>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct AgentManifest {
+ id: String,
+ version: Option<ManifestVersion>,
+ min_engine_version: Option<u32>,
+ #[serde(rename = "updated_at")]
+ _updated_at: Option<String>,
+ #[serde(default)]
+ aliases: Vec<String>,
+ #[serde(default)]
+ rules: Vec<ManifestRule>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+#[serde(deny_unknown_fields)]
+struct ManifestRule {
+ id: String,
+ state: Option<ManifestState>,
+ #[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<ManifestGate>,
+ #[serde(default)]
+ any: Vec<ManifestGate>,
+ #[serde(default, rename = "not")]
+ not_gate: Vec<ManifestGate>,
+ #[serde(default)]
+ contains: Vec<String>,
+ #[serde(default)]
+ regex: Vec<String>,
+ #[serde(default)]
+ line_regex: Vec<String>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+#[serde(deny_unknown_fields)]
+struct ManifestGate {
+ #[serde(default)]
+ all: Vec<ManifestGate>,
+ #[serde(default)]
+ any: Vec<ManifestGate>,
+ #[serde(default, rename = "not")]
+ not_gate: Vec<ManifestGate>,
+ #[serde(default)]
+ contains: Vec<String>,
+ #[serde(default)]
+ regex: Vec<String>,
+ #[serde(default)]
+ line_regex: Vec<String>,
+}
+
+#[derive(Debug, Clone)]
+struct CompiledRule {
+ gate: CompiledGate,
+}
+
+#[derive(Debug, Clone)]
+struct CompiledGate {
+ all: Vec<CompiledGate>,
+ any: Vec<CompiledGate>,
+ not_gate: Vec<CompiledGate>,
+ contains: Vec<String>,
+ regex: Vec<Regex>,
+ line_regex: Vec<Regex>,
+}
+
+#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+enum ManifestState {
+ Idle,
+ Working,
+ Blocked,
+ Unknown,
+}
+
+impl From<ManifestState> 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")),
+ ("devin", include_str!("manifests/devin.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")),
+ ("letta", include_str!("manifests/letta.toml")),
+ ("maki", include_str!("manifests/maki.toml")),
+ ("muse", include_str!("manifests/muse.toml")),
+ ("opencode", include_str!("manifests/opencode.toml")),
+ ("pi", include_str!("manifests/pi.toml")),
+ ("qodercli", include_str!("manifests/qodercli.toml")),
+ ("qwen", include_str!("manifests/qwen.toml")),
+ ("copilot", include_str!("manifests/github-copilot.toml")),
+];
+
+static MANIFEST_CACHE: OnceLock<RwLock<ManifestCache>> = OnceLock::new();
+static MANIFEST_RELOAD_LOCK: OnceLock<Mutex<()>> = 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 fn reload_manifests() -> Vec<AgentManifestSummary> {
+ 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
+}
+
+#[allow(dead_code)] // parity with herdr: selective reload after remote manifest refresh
+pub(crate) fn reload_manifests_for_agents(agents: &[Agent]) {
+ if agents.is_empty() {
+ return;
+ }
+
+ let _reload_guard = MANIFEST_RELOAD_LOCK
+ .get_or_init(|| Mutex::new(()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ let lock = manifest_cache();
+ let replacements = Agent::SCREEN_MANIFEST_AGENTS
+ .into_iter()
+ .filter(|agent| agents.contains(agent))
+ .map(|agent| (agent, load_manifest_uncached(agent)))
+ .collect::<Vec<_>>();
+ let mut cache = match lock.write() {
+ Ok(guard) => guard,
+ Err(poisoned) => poisoned.into_inner(),
+ };
+ for (agent, replacement) in replacements {
+ if let Some((_, loaded)) = cache
+ .manifests
+ .iter_mut()
+ .find(|(cached_agent, _)| *cached_agent == agent)
+ {
+ *loaded = replacement;
+ }
+ }
+}
+
+fn manifest_cache() -> &'static RwLock<ManifestCache> {
+ MANIFEST_CACHE.get_or_init(|| RwLock::new(build_manifest_cache()))
+}
+
+fn build_manifest_cache() -> ManifestCache {
+ ManifestCache {
+ manifests: Agent::SCREEN_MANIFEST_AGENTS
+ .into_iter()
+ .map(|agent| (agent, load_manifest_uncached(agent)))
+ .collect(),
+ }
+}
+
+fn manifest_summaries_from_cache(cache: &ManifestCache) -> Vec<AgentManifestSummary> {
+ 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,
+ }
+}
+
+#[allow(dead_code)] // shim for existing callers; detect_with_osc is the real path
+pub fn detect(agent: Agent, screen_content: &str) -> AgentDetection {
+ detect_with_osc(
+ agent,
+ DetectionInput {
+ screen: screen_content,
+ osc_title: "",
+ osc_progress: "",
+ },
+ )
+}
+
+pub fn detect_with_osc(agent: Agent, input: DetectionInput<'_>) -> AgentDetection {
+ let Some(loaded) = load_manifest(agent) else {
+ return fallback_explain(Some(agent), None, false).into_detection();
+ };
+ evaluate_loaded_manifest(agent, input, loaded, false).into_detection()
+}
+
+pub fn explain(agent: Agent, screen_content: &str) -> DetectionExplain {
+ explain_with_input(
+ agent,
+ DetectionInput {
+ screen: screen_content,
+ osc_title: "",
+ osc_progress: "",
+ },
+ )
+}
+
+pub fn explain_with_input(agent: Agent, input: DetectionInput<'_>) -> DetectionExplain {
+ let Some(loaded) = load_manifest(agent) else {
+ return fallback_explain(Some(agent), None, true);
+ };
+ evaluate_loaded_manifest(agent, input, 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)
+}
+
+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,
+ input: DetectionInput<'_>,
+ 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.iter())
+ {
+ let region_text = region(input, &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<Agent>,
+ context: Option<(LoadedManifest, Vec<EvaluatedRule>)>,
+ 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<LoadedManifest> {
+ 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<LoadedManifest> {
+ 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<String>,
+ cached_remote_version: Option<String>,
+ local_override_shadowing_remote: bool,
+) -> Result<LoadedManifest, String> {
+ let compiled_rules = compile_manifest(&manifest)?.into();
+ Ok(LoadedManifest {
+ manifest,
+ compiled_rules,
+ source,
+ warning,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ })
+}
+
+fn bundled_loaded_manifest(
+ agent: Agent,
+ manifest: AgentManifest,
+ warning: Option<String>,
+ cached_remote_version: Option<String>,
+ 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<AgentManifest> {
+ 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<AgentManifest, String> {
+ 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<LoadedManifest> {
+ 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,
+ #[allow(dead_code)] // carried for parity with herdr manifest bookkeeping
+ pub(crate) version: ManifestVersion,
+}
+
+pub(crate) fn parse_manifest(content: &str) -> Result<AgentManifest, String> {
+ let manifest = toml::from_str::<AgentManifest>(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<ParsedRemoteManifest, String> {
+ 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))?;
+ if rule.region.trim().starts_with("top_non_empty_lines(")
+ && manifest
+ .min_engine_version
+ .is_some_and(|version| version < TOP_NON_EMPTY_LINES_ENGINE_VERSION)
+ {
+ return Err(format!(
+ "rule {} uses top_non_empty_lines but min_engine_version is below {}",
+ rule.id, TOP_NON_EMPTY_LINES_ENGINE_VERSION
+ ));
+ }
+ 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"
+ | "osc_title"
+ | "osc_progress" => Ok(()),
+ _ if region_count(trimmed, "bottom_lines").is_some()
+ || region_count(trimmed, "bottom_non_empty_lines").is_some()
+ || top_region_count(trimmed).is_some() =>
+ {
+ Ok(())
+ }
+ _ => Err(trimmed.to_string()),
+ }
+}
+
+fn override_path(agent: Agent) -> Option<PathBuf> {
+ Some(
+ crate::manifest_update::override_dir()
+ .join(format!("{}.toml", agent_label(agent))),
+ )
+}
+
+struct RemoteUpdateStatus {
+ last_result: String,
+ last_error: Option<String>,
+}
+
+/// dingger has no background remote-update machinery; the `dingger manifests
+/// update` CLI command stores refreshed manifests in the remote store
+/// directory, and `read_remote_manifest` picks them up on the next cache
+/// reload. There is no fetch status to report.
+fn remote_update_status(_agent: Agent) -> Option<RemoteUpdateStatus> {
+ None
+}
+
+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<Vec<CompiledRule>, 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<CompiledGate, String> {
+ Ok(CompiledGate {
+ all: gate
+ .all
+ .iter()
+ .map(compile_gate)
+ .collect::<Result<_, _>>()?,
+ any: gate
+ .any
+ .iter()
+ .map(compile_gate)
+ .collect::<Result<_, _>>()?,
+ not_gate: gate
+ .not_gate
+ .iter()
+ .map(compile_gate)
+ .collect::<Result<_, _>>()?,
+ 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::<Result<_, _>>()?,
+ line_regex: gate
+ .line_regex
+ .iter()
+ .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string()))
+ .collect::<Result<_, _>>()?,
+ })
+}
+
+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>(input: DetectionInput<'a>, spec: &str) -> &'a str {
+ let trimmed = spec.trim();
+ // OSC regions source from their dedicated fields, not the screen.
+ match trimmed {
+ "osc_title" => return input.osc_title,
+ "osc_progress" => return input.osc_progress,
+ _ => {}
+ }
+ // All other regions operate on the screen content as before.
+ let content = input.screen;
+ 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);
+ }
+ if let Some(count) = top_region_count(trimmed) {
+ return top_non_empty_lines(content, count);
+ }
+ ""
+ }
+ }
+}
+
+fn region_count(spec: &str, name: &str) -> Option<usize> {
+ spec.strip_prefix(name)
+ .and_then(|rest| rest.strip_prefix('('))
+ .and_then(|rest| rest.strip_suffix(')'))
+ .and_then(|count| count.parse::<usize>().ok())
+}
+
+const TOP_NON_EMPTY_LINES_ENGINE_VERSION: u32 = 3;
+const MAX_TOP_REGION_LINE_COUNT: usize = u16::MAX as usize;
+
+fn top_region_count(spec: &str) -> Option<usize> {
+ let count = spec
+ .strip_prefix("top_non_empty_lines")?
+ .strip_prefix('(')?
+ .strip_suffix(')')?;
+ if count.starts_with('0') || !count.bytes().all(|byte| byte.is_ascii_digit()) {
+ return None;
+ }
+ count
+ .parse::<usize>()
+ .ok()
+ .filter(|count| *count <= MAX_TOP_REGION_LINE_COUNT)
+}
+
+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 top_non_empty_lines(content: &str, count: usize) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ let Some(end_index) = lines
+ .iter()
+ .enumerate()
+ .filter(|(_, line)| !line.trim().is_empty())
+ .take(count)
+ .last()
+ .map(|(index, _)| index)
+ else {
+ return "";
+ };
+ let byte_offset = line_start_offset(content, &lines, end_index + 1);
+ &content[..byte_offset]
+}
+
+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::<usize>();
+ &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<usize> {
+ 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<usize> {
+ 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::<usize>()
+ .min(content.len())
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/detect-core/src/manifest/tests.rs b/crates/detect-core/src/manifest/tests.rs
new file mode 100644
index 0000000..3d9a5a2
--- /dev/null
+++ b/crates/detect-core/src/manifest/tests.rs
@@ -0,0 +1,830 @@
+use super::*;
+
+// Codex is only a registry key here; behavior tests supply synthetic rules.
+fn remote_manifest(version: &str, state: &str, contains: &str) -> String {
+ format!(
+ r#"
+id = "codex"
+version = "{version}"
+min_engine_version = 1
+updated_at = "2026-06-10T12:00:00Z"
+
+[[rules]]
+id = "test"
+state = "{state}"
+contains = ["{contains}"]
+"#
+ )
+}
+
+fn local_manifest(state: &str, contains: &str) -> String {
+ format!(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "test"
+state = "{state}"
+contains = ["{contains}"]
+"#
+ )
+}
+
+fn rules_manifest(rules: &str) -> String {
+ format!(
+ r#"
+id = "codex"
+
+{rules}
+"#
+ )
+}
+
+fn with_manifest_dirs<T>(name: &str, f: impl FnOnce() -> T) -> T {
+ let _guard = crate::test_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::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::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() {
+ with_manifest_dirs("no-match", || {
+ write_local_codex(&local_manifest("working", "active-marker"));
+ let explain = explain(Agent::Codex, "unmatched-marker");
+
+ 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 paired_alternatives_require_complete_gates_at_bottom_buffer() {
+ with_manifest_dirs("paired-alternatives", || {
+ write_local_codex(&rules_manifest(
+ r#"
+[[rules]]
+id = "paired"
+state = "blocked"
+region = "bottom_non_empty_lines(3)"
+visible_blocker = true
+any = [
+ { all = [
+ { contains = ["alpha heading"] },
+ { regex = ['alpha\s+control\s*\z'] },
+ ] },
+ { all = [
+ { contains = ["beta heading"] },
+ { regex = ['beta\s+control\s*\z'] },
+ ] },
+]
+"#,
+ ));
+ for text in [
+ "alpha heading\nalpha control",
+ "beta heading\nbeta\ncontrol",
+ ] {
+ let result = explain(Agent::Codex, text);
+ assert_eq!(result.state, AgentState::Blocked);
+ assert!(result.visible_blocker);
+ }
+ for text in [
+ "alpha heading\nbeta control",
+ "beta heading\nalpha control",
+ "beta control",
+ "beta heading",
+ "beta heading\nbeta control\nnewer content",
+ "beta heading\nfiller one\nfiller two\nbeta control",
+ ] {
+ let result = explain(Agent::Codex, text);
+ assert!(result.matched_rule.is_none(), "{text}");
+ assert!(!result.visible_blocker, "{text}");
+ }
+ });
+}
+
+#[test]
+fn remote_manifest_loads_between_local_override_and_bundled() {
+ with_manifest_dirs("remote-source", || {
+ write_remote_codex(&remote_manifest("9999.01.01.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("9999.01.01.1"));
+ assert_eq!(
+ explain.cached_remote_version.as_deref(),
+ Some("9999.01.01.1")
+ );
+ });
+}
+
+#[test]
+fn fallback_explain_preserves_active_manifest_version() {
+ with_manifest_dirs("fallback-version", || {
+ write_remote_codex(&remote_manifest("9999.01.01.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("9999.01.01.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!(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("9999.01.01.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("9999.01.01.1")
+ );
+ });
+}
+
+#[test]
+fn invalid_local_override_falls_back_to_cached_remote_manifest() {
+ with_manifest_dirs("invalid-local-remote-fallback", || {
+ write_remote_codex(&remote_manifest("9999.01.01.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("9999.01.01.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("9999.01.01.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("9999.01.01.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("9999.01.01.2")
+ );
+ assert_eq!(
+ reloaded.matched_rule.as_ref().map(|rule| rule.id.as_str()),
+ Some("test")
+ );
+ });
+}
+
+#[test]
+fn compiled_rules_are_shared_until_manifest_reload() {
+ with_manifest_dirs("shared-compiled-rules", || {
+ write_remote_codex(&format!(
+ "{}\nregex = ['^cached-[a-z]+$']\n",
+ remote_manifest("9999.01.01.1", "blocked", "cached-ready")
+ ));
+ let first = load_manifest(Agent::Codex).unwrap();
+ let second = load_manifest(Agent::Codex).unwrap();
+ assert!(!first.compiled_rules.is_empty());
+ assert_eq!(
+ first.compiled_rules.as_ptr(),
+ second.compiled_rules.as_ptr(),
+ "cached loads must retain the same compiled rules and regex search caches"
+ );
+
+ write_remote_codex_without_reload(&format!(
+ "{}\nregex = ['^new-[a-z]+$']\n",
+ remote_manifest("9999.01.01.2", "working", "new-ready")
+ ));
+ let unchanged = load_manifest(Agent::Codex).unwrap();
+ assert_eq!(
+ first.compiled_rules.as_ptr(),
+ unchanged.compiled_rules.as_ptr()
+ );
+
+ reload_manifests_for_agents(&[Agent::Codex]);
+ let reloaded = load_manifest(Agent::Codex).unwrap();
+ let shared_reload = load_manifest(Agent::Codex).unwrap();
+ assert_ne!(
+ first.compiled_rules.as_ptr(),
+ reloaded.compiled_rules.as_ptr()
+ );
+ assert_eq!(
+ reloaded.compiled_rules.as_ptr(),
+ shared_reload.compiled_rules.as_ptr()
+ );
+ assert!(compiled_rule_matches(
+ &first.compiled_rules[0],
+ "cached-ready"
+ ));
+ assert!(!compiled_rule_matches(
+ &first.compiled_rules[0],
+ "new-ready"
+ ));
+ assert_eq!(
+ explain(Agent::Codex, "new-ready").state,
+ AgentState::Working
+ );
+
+ std::thread::scope(|scope| {
+ for _ in 0..4 {
+ let reloaded = &reloaded;
+ scope.spawn(move || {
+ let loaded = load_manifest(Agent::Codex).unwrap();
+ assert_eq!(
+ loaded.compiled_rules.as_ptr(),
+ reloaded.compiled_rules.as_ptr()
+ );
+ for _ in 0..8 {
+ assert_eq!(detect(Agent::Codex, "new-ready").state, AgentState::Working);
+ }
+ });
+ }
+ });
+ });
+}
+
+#[test]
+fn osc_regions_use_separate_inputs_and_share_rule_priority() {
+ with_manifest_dirs("osc-regions", || {
+ write_local_codex(&rules_manifest(
+ r#"
+[[rules]]
+id = "screen"
+state = "idle"
+priority = 10
+region = "whole_recent"
+visible_idle = true
+contains = ["screen-marker"]
+
+[[rules]]
+id = "title"
+state = "working"
+priority = 20
+region = "osc_title"
+visible_working = true
+regex = ['^title-marker$']
+
+[[rules]]
+id = "progress"
+state = "blocked"
+priority = 30
+region = "osc_progress"
+visible_blocker = true
+regex = ['^progress-marker$']
+"#,
+ ));
+ for (screen, title, progress, state, rule) in [
+ ("screen-marker", "", "", AgentState::Idle, "screen"),
+ (
+ "screen-marker",
+ "title-marker",
+ "",
+ AgentState::Working,
+ "title",
+ ),
+ (
+ "screen-marker",
+ "title-marker",
+ "progress-marker",
+ AgentState::Blocked,
+ "progress",
+ ),
+ (
+ "screen-marker title-marker progress-marker",
+ "",
+ "",
+ AgentState::Idle,
+ "screen",
+ ),
+ ] {
+ let input = DetectionInput {
+ screen,
+ osc_title: title,
+ osc_progress: progress,
+ };
+ let result = explain_with_input(Agent::Codex, input);
+ assert_eq!(result.state, state);
+ assert_eq!(
+ result
+ .matched_rule
+ .as_ref()
+ .map(|matched| matched.id.as_str()),
+ Some(rule)
+ );
+ let detection =
+ crate::detect_agent_with_osc(Some(Agent::Codex), screen, title, progress);
+ assert_eq!(detection.state, state);
+ assert_eq!(detection.visible_idle, state == AgentState::Idle);
+ assert_eq!(detection.visible_working, state == AgentState::Working);
+ assert_eq!(detection.visible_blocker, state == AgentState::Blocked);
+ }
+ let swapped = explain_with_input(
+ Agent::Codex,
+ DetectionInput {
+ screen: "",
+ osc_title: "progress-marker",
+ osc_progress: "title-marker",
+ },
+ );
+ assert!(swapped.matched_rule.is_none());
+ });
+}
+
+#[test]
+fn skip_rule_suppresses_state_update_without_visible_state_evidence() {
+ with_manifest_dirs("skip-rule", || {
+ write_local_codex(&rules_manifest(
+ r#"
+[[rules]]
+id = "activity"
+state = "working"
+priority = 10
+visible_working = true
+contains = ["activity-marker"]
+
+[[rules]]
+id = "overlay"
+state = "unknown"
+priority = 20
+skip_state_update = true
+contains = ["overlay-marker"]
+"#,
+ ));
+ let screen = "activity-marker overlay-marker";
+ let result = explain(Agent::Codex, screen);
+ assert_eq!(result.state, AgentState::Unknown);
+ assert!(result.skip_state_update);
+ assert_eq!(
+ result.skipped_update_reason.as_deref(),
+ Some("matched_rule:overlay")
+ );
+ assert!(!result.visible_idle);
+ assert!(!result.visible_working);
+ assert!(!result.visible_blocker);
+ assert!(detect(Agent::Codex, screen).skip_state_update);
+ });
+}
+
+#[test]
+fn screen_regions_extract_structure_without_classifying_agent_state() {
+ for (screen, spec, expected) in [
+ ("old\n\nnew\n", "bottom_lines(2)", "\nnew\n"),
+ (
+ "before\n› input\nafter\n",
+ "after_last_prompt_marker",
+ "after\n",
+ ),
+ (
+ "before\n› input\nafter\n",
+ "before_current_prompt_marker",
+ "before\n",
+ ),
+ (
+ "before\n› input\nafter\n",
+ "whole_recent_without_current_prompt_marker",
+ "",
+ ),
+ (
+ "no marker\n",
+ "whole_recent_without_current_prompt_marker",
+ "no marker\n",
+ ),
+ (
+ "• old\n■ latest\n› input\n",
+ "current_prompt_block_marker",
+ "■ latest",
+ ),
+ (
+ "• old\n■ latest\n› input\n",
+ "after_current_prompt_block_marker",
+ "■ latest\n› input\n",
+ ),
+ ("› old\n• new\n", "current_prompt_block_marker", ""),
+ (
+ "above\n\n───\nbody\n───\nfooter\n",
+ "above_prompt_box",
+ "above\n\n",
+ ),
+ (
+ "above\n\n───\nbody\n───\nfooter\n",
+ "last_non_empty_above_prompt_box",
+ "above",
+ ),
+ (
+ "above\n───\nbody\n───\nfooter\n",
+ "prompt_box_body",
+ "body\n",
+ ),
+ (
+ "above\n───\nbody\n───\nfooter\n",
+ "after_last_horizontal_rule",
+ "footer\n",
+ ),
+ ] {
+ assert_eq!(
+ region(
+ DetectionInput {
+ screen,
+ osc_title: "",
+ osc_progress: ""
+ },
+ spec
+ ),
+ expected,
+ "region={spec}"
+ );
+ }
+}
+
+#[test]
+fn all_bundled_manifests_parse_and_validate() {
+ for agent in Agent::SCREEN_MANIFEST_AGENTS {
+ assert!(
+ bundled_manifest(agent).is_some(),
+ "missing bundled manifest for {}",
+ agent_label(agent)
+ );
+ }
+}
+
+#[test]
+fn manifest_validation_rejects_unknown_fields_empty_rules_invalid_regions_and_regexes() {
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "typo"
+state = "working"
+contain = ["Working"]
+"#
+ )
+ .is_err());
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "empty"
+state = "working"
+"#
+ )
+ .is_err());
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_region"
+state = "working"
+region = "after_last_promt_marker"
+contains = ["Working"]
+"#
+ )
+ .is_err());
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_regex"
+state = "working"
+regex = ["["]
+"#
+ )
+ .is_err());
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_nested_regex"
+state = "working"
+any = [{ line_regex = ["["] }]
+"#
+ )
+ .is_err());
+}
+
+#[test]
+fn manifest_validation_keeps_skip_rules_neutral() {
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_skip_state"
+state = "idle"
+skip_state_update = true
+contains = ["menu"]
+"#
+ )
+ .is_err());
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_skip_visible"
+state = "unknown"
+skip_state_update = true
+visible_blocker = true
+contains = ["menu"]
+"#
+ )
+ .is_err());
+}
+
+#[test]
+fn manifest_validation_rejects_excessive_rule_count() {
+ let mut manifest = String::from(
+ r#"
+id = "codex"
+"#,
+ );
+ for index in 0..129 {
+ manifest.push_str(&format!(
+ r#"
+[[rules]]
+id = "rule_{index}"
+state = "idle"
+contains = ["ready"]
+"#
+ ));
+ }
+ assert!(parse_manifest(&manifest).is_err());
+}
+
+#[test]
+fn manifest_validation_rejects_excessive_gate_depth() {
+ let manifest = r#"
+id = "codex"
+
+[[rules]]
+id = "deep"
+state = "idle"
+contains = ["ready"]
+all = [
+ { contains = ["1"], all = [
+ { contains = ["2"], all = [
+ { contains = ["3"], all = [
+ { contains = ["4"], all = [
+ { contains = ["5"], all = [
+ { contains = ["6"], all = [
+ { contains = ["7"], all = [
+ { contains = ["8"], all = [
+ { contains = ["9"] },
+ ] },
+ ] },
+ ] },
+ ] },
+ ] },
+ ] },
+ ] },
+ ] },
+]
+"#;
+ assert!(parse_manifest(manifest).is_err());
+}
+
+#[test]
+fn manifest_validation_rejects_excessive_matchers() {
+ let matchers = (0..33)
+ .map(|index| format!(r#""m{index}""#))
+ .collect::<Vec<_>>()
+ .join(", ");
+ let manifest = format!(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "many"
+state = "idle"
+contains = [{matchers}]
+"#
+ );
+ assert!(parse_manifest(&manifest).is_err());
+}
+
+#[test]
+fn bottom_non_empty_lines_uses_bottom_occurrence_for_repeated_text() {
+ let content = "marker\nold\n\nmiddle\nmarker\nnew\n";
+ assert_eq!(
+ region(
+ DetectionInput {
+ screen: content,
+ osc_title: "",
+ osc_progress: ""
+ },
+ "bottom_non_empty_lines(2)"
+ ),
+ "marker\nnew\n"
+ );
+}
+
+#[test]
+fn top_non_empty_lines_uses_top_occurrence_for_repeated_text() {
+ let content = "\nmarker\nold\n\nmiddle\nmarker\nnew\n";
+ assert_eq!(
+ region(
+ DetectionInput {
+ screen: content,
+ osc_title: "",
+ osc_progress: ""
+ },
+ "top_non_empty_lines(2)"
+ ),
+ "\nmarker\nold\n"
+ );
+}
+
+#[test]
+fn top_non_empty_lines_requires_a_canonical_positive_bounded_count() {
+ let name = "top_non_empty_lines";
+ assert!(validate_region_name(&format!("{name}(1)")).is_ok());
+ assert!(validate_region_name(&format!("{name}({})", u16::MAX)).is_ok());
+ for count in ["0", "01", "+1", "65536", "999999999999999999999999"] {
+ assert!(
+ validate_region_name(&format!("{name}({count})")).is_err(),
+ "{name} accepted invalid count {count}"
+ );
+ }
+}
+
+#[test]
+fn top_non_empty_lines_requires_engine_three_when_declared() {
+ let manifest = r#"
+id = "codex"
+version = "1"
+min_engine_version = 2
+
+[[rules]]
+id = "background"
+state = "working"
+region = " top_non_empty_lines(1) "
+contains = ["active"]
+"#;
+ assert!(parse_manifest(manifest).is_err());
+}
diff --git a/crates/detect-core/src/manifest_update.rs b/crates/detect-core/src/manifest_update.rs
new file mode 100644
index 0000000..1bea8a1
--- /dev/null
+++ b/crates/detect-core/src/manifest_update.rs
@@ -0,0 +1,156 @@
+//! Compatibility shim for code extracted from herdr's `src/detect/`.
+//!
+//! herdr's `manifest_update.rs` handled remote catalog fetching, version
+//! comparison, and on-disk manifest storage. dingger keeps the version type,
+//! the engine version contract, and the local storage paths; remote fetching
+//! is an explicit CLI action that populates the remote store directory.
+
+use std::path::PathBuf;
+
+use crate::{agent_label, Agent};
+
+/// Engine contract version declared by the manifest schema in use. Remote
+/// manifests with a higher `min_engine_version` are rejected.
+pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 3;
+
+pub(crate) fn config_dir() -> PathBuf {
+ if let Some(dir) = std::env::var_os("DINGGER_CONFIG_DIR") {
+ return PathBuf::from(dir);
+ }
+ if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME") {
+ return PathBuf::from(dir).join("dingger");
+ }
+ dirs_home()
+ .map(|home| home.join(".config").join("dingger"))
+ .unwrap_or_else(|| PathBuf::from(".dingger"))
+}
+
+pub(crate) fn state_dir() -> PathBuf {
+ if let Some(dir) = std::env::var_os("DINGGER_STATE_DIR") {
+ return PathBuf::from(dir);
+ }
+ if let Some(dir) = std::env::var_os("XDG_STATE_HOME") {
+ return PathBuf::from(dir).join("dingger");
+ }
+ dirs_home()
+ .map(|home| home.join(".local").join("state").join("dingger"))
+ .unwrap_or_else(|| PathBuf::from(".dingger"))
+}
+
+fn dirs_home() -> Option<PathBuf> {
+ std::env::var_os("HOME").map(PathBuf::from)
+}
+
+/// Directory where remotely refreshed manifests are stored by
+/// `dingger manifests update`.
+pub fn remote_store_dir() -> PathBuf {
+ state_dir().join("agent-detection")
+}
+
+pub fn remote_manifest_path(agent: Agent) -> PathBuf {
+ remote_store_dir().join(format!("{}.toml", agent_label(agent)))
+}
+
+/// Directory for local override manifests that shadow bundled and remote ones.
+pub(crate) fn override_dir() -> PathBuf {
+ config_dir().join("agent-detection")
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct ManifestVersion(String);
+
+impl ManifestVersion {
+ pub(crate) fn parse(value: &str) -> Result<Self, String> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Err("version must not be empty".to_string());
+ }
+ for segment in trimmed.split('.') {
+ if segment.is_empty() {
+ return Err(format!("version {trimmed:?} contains an empty segment"));
+ }
+ if !segment.chars().all(|ch| ch.is_ascii_digit()) {
+ return Err(format!("version {trimmed:?} must be dotted numeric"));
+ }
+ segment
+ .parse::<u64>()
+ .map_err(|_| format!("version {trimmed:?} contains an oversized segment"))?;
+ }
+ Ok(Self(trimmed.to_string()))
+ }
+}
+
+impl std::fmt::Display for ManifestVersion {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+impl<'de> serde::Deserialize<'de> for ManifestVersion {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ Self::parse(&value).map_err(serde::de::Error::custom)
+ }
+}
+
+impl serde::Serialize for ManifestVersion {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&self.0)
+ }
+}
+
+impl Ord for ManifestVersion {
+ fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+ use std::cmp::Ordering;
+ let mut left = self.0.split('.');
+ let mut right = other.0.split('.');
+
+ loop {
+ match (left.next(), right.next()) {
+ (Some(left), Some(right)) => {
+ let left = left.parse::<u64>().unwrap_or(0);
+ let right = right.parse::<u64>().unwrap_or(0);
+ match left.cmp(&right) {
+ Ordering::Equal => {}
+ ordering => return ordering,
+ }
+ }
+ (Some(left), None) => {
+ let left = left.parse::<u64>().unwrap_or(0);
+ if left == 0 {
+ continue;
+ }
+ return Ordering::Greater;
+ }
+ (None, Some(right)) => {
+ let right = right.parse::<u64>().unwrap_or(0);
+ if right == 0 {
+ continue;
+ }
+ return Ordering::Less;
+ }
+ (None, None) => return Ordering::Equal,
+ }
+ }
+ }
+}
+
+impl PartialOrd for ManifestVersion {
+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+impl PartialEq for ManifestVersion {
+ fn eq(&self, other: &Self) -> bool {
+ self.cmp(other) == std::cmp::Ordering::Equal
+ }
+}
+
+impl Eq for ManifestVersion {}
diff --git a/crates/detect-core/src/manifests/amp.toml b/crates/detect-core/src/manifests/amp.toml
new file mode 100644
index 0000000..1fc5dfa
--- /dev/null
+++ b/crates/detect-core/src/manifests/amp.toml
@@ -0,0 +1,65 @@
+id = "amp"
+version = "2026.07.09.1"
+min_engine_version = 2
+updated_at = "2026-07-09T00:00:00Z"
+aliases = ["amp-local"]
+
+[[rules]]
+id = "osc_title_plugin_confirmation_blocked"
+state = "blocked"
+priority = 1100
+region = "osc_title"
+visible_blocker = true
+contains = ["Plugin confirmation needed"]
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 1050
+region = "osc_title"
+visible_working = true
+regex = ['^[\x{2800}-\x{28FF}] ']
+
+[[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 = "status_footer_working"
+state = "working"
+priority = 200
+region = "bottom_non_empty_lines(5)"
+visible_working = true
+line_regex = ['(?i)^\s*╰\s+\S+\s+(thinking|streaming|running tools|waiting)\s+─']
+
+[[rules]]
+id = "esc_cancel_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+contains = ["esc to cancel"]
+
+[[rules]]
+id = "osc_title_idle"
+state = "idle"
+priority = 50
+region = "osc_title"
+visible_idle = true
+contains = [" - amp - "]
+not = [
+ { regex = ['^[\x{2800}-\x{28FF}] '] },
+ { contains = ["Plugin confirmation needed"] },
+]
diff --git a/crates/detect-core/src/manifests/antigravity.toml b/crates/detect-core/src/manifests/antigravity.toml
new file mode 100644
index 0000000..5daa8de
--- /dev/null
+++ b/crates/detect-core/src/manifests/antigravity.toml
@@ -0,0 +1,80 @@
+id = "agy"
+version = "2026.10.05.1"
+min_engine_version = 1
+updated_at = "2026-10-05T00:00:00Z"
+aliases = ["antigravity", "antigravity-cli"]
+
+# Evidence: Antigravity CLI 1.2.17 live pane reads, plus reporter captures
+# from 1.1.28 through 1.2.16.
+#
+# Dialogs replace the prompt box and end with a key-hint row directly above
+# the "esc to cancel" footer:
+# permission: "↑/↓ Navigate · tab Amend · ctrl+g edit/expand command"
+# "↑/↓ Navigate · tab Amend · f full diff"
+# question: "↑/↓ Navigate · enter Select · esc Skip"
+# trust: "↑/↓ Navigate · enter Confirm"
+# slash menu: "↑/↓ Navigate · enter Select · tab Complete" (idle)
+# Some captures hold only the two footer rows, so dialog rules must not
+# require the question text.
+#
+# Working turns draw a braille spinner followed by either an -ing word or a
+# thought summary above the prompt box, and the footer reads "esc to cancel"
+# directly under the prompt box's bottom border. The idle footer reads
+# "? for shortcuts".
+#
+# Background tasks and subagents show "· N task(s)" in the footer and a task
+# panel under the prompt box. Once the agent is back at its prompt they do
+# not count as working.
+
+[[rules]]
+id = "permission_prompt"
+state = "blocked"
+priority = 300
+region = "bottom_non_empty_lines(2)"
+visible_blocker = true
+contains = ["navigate", "tab amend"]
+
+[[rules]]
+id = "question_prompt"
+state = "blocked"
+priority = 290
+region = "bottom_non_empty_lines(2)"
+visible_blocker = true
+contains = ["navigate", "esc skip"]
+
+[[rules]]
+id = "trust_prompt"
+state = "blocked"
+priority = 280
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = ["do you trust the contents of this project?", "yes, i trust this folder", "enter confirm"]
+
+# Pre-1.1.28 permission dialog.
+[[rules]]
+id = "legacy_permission_prompt"
+state = "blocked"
+priority = 270
+region = "whole_recent"
+visible_blocker = true
+contains = ["requesting permission for:"]
+any = [
+ { contains = ["do you want to proceed?"] },
+ { contains = ["tab amend", "edit command"] },
+]
+
+[[rules]]
+id = "esc_cancel_footer_working"
+state = "working"
+priority = 110
+region = "bottom_non_empty_lines(2)"
+visible_working = true
+regex = ['(?m)^\s*─{3,}\s*esc to cancel\b']
+
+[[rules]]
+id = "spinner_working"
+state = "working"
+priority = 100
+region = "bottom_non_empty_lines(12)"
+visible_working = true
+line_regex = ['^\s*[\u2801-\u28FF]+\s+\S']
diff --git a/crates/detect-core/src/manifests/claude.toml b/crates/detect-core/src/manifests/claude.toml
new file mode 100644
index 0000000..0c7bdcf
--- /dev/null
+++ b/crates/detect-core/src/manifests/claude.toml
@@ -0,0 +1,230 @@
+id = "claude"
+version = "2026.09.11.1"
+min_engine_version = 2
+updated_at = "2026-09-11T00:00:00Z"
+aliases = ["claude-code"]
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 1100
+region = "osc_title"
+visible_working = true
+# Braille covers <= 2.1.227; half-circles are the 2.1.228 busy spinner.
+regex = ['^[\x{2800}-\x{28FF}\x{25D0}-\x{25D3}] ']
+
+[[rules]]
+id = "live_turn_working"
+state = "working"
+priority = 970
+region = "bottom_non_empty_lines(12)"
+visible_working = true
+any = [
+ { line_regex = ['^\s*[⏸⏵].*esc to interrupt(?:\s|·|$)'] },
+ { line_regex = ['^\s*[\x{002A}\x{00B7}\x{2722}\x{2733}\x{2736}\x{273B}\x{273D}]\s+\S.*…(?:\s+\(\d+[smh](?:\s|·)|\s*$)'] },
+]
+
+[[rules]]
+id = "background_agents_working"
+state = "working"
+priority = 965
+region = "last_non_empty_above_prompt_box"
+visible_working = true
+line_regex = ['^\s*[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}]\s+Waiting for [1-9]\d* background agents? to finish\s*$']
+
+[[rules]]
+id = "background_mcp_task_working"
+state = "working"
+priority = 965
+region = "bottom_non_empty_lines(12)"
+visible_working = true
+# Claude renders activity summaries at column zero; wrapped continuations are indented.
+# Keeping that shape prevents user prompt text from impersonating this signal.
+regex = ['(?m)^[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}][ \t]+\S[^\n]*?(?:\n[ \t]+[^\n]*?){0,3}·(?:[ \t]+|\n[ \t]*)[1-9]\d*(?:[ \t]+|\n[ \t]*)MCP(?:[ \t]+|\n[ \t]*)tasks?(?:[ \t]+|\n[ \t]*)still(?:[ \t]+|\n[ \t]*)running[ \t]*$']
+not = [
+ { contains = ["do you want to proceed?"] },
+ { contains = ["esc to cancel"] },
+ { contains = ["waiting for permission"] },
+ { contains = ["do you want to allow this connection?"] },
+ { contains = ["tab to amend"] },
+ { contains = ["ctrl+e to explain"] },
+]
+
+[[rules]]
+id = "btw_overlay_working"
+state = "working"
+priority = 975
+region = "bottom_non_empty_lines(5)"
+visible_working = true
+line_regex = [
+ '^\s*/btw(?:\s|$)',
+ '(?i)esc to close\s*$',
+]
+
+[[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_blocked_form"
+state = "blocked"
+priority = 980
+region = "after_last_horizontal_rule"
+visible_blocker = true
+contains = ["esc to cancel"]
+any = [
+ { contains = ["enter to confirm"] },
+ { contains = ["enter to select"], 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 = "mcp_elicitation_prompt"
+state = "blocked"
+priority = 980
+region = "whole_recent"
+visible_blocker = true
+# MCP elicitation dialogs (elicitation/create) show Accept/Decline controls
+# with an "Esc to cancel" footer but no Enter hint, so live_blocked_form
+# cannot see them (issue #3283). Gate on the invariant header line, the
+# Accept/Decline control line, and the cancel footer.
+contains = ["esc to cancel"]
+line_regex = ['(?i)^\s*MCP server ["\x{201C}].+["\x{201D}] requests your input\s*$']
+all = [
+ { any = [
+ { line_regex = ['^\s*\x{276F}?\s*Accept\b'] },
+ { line_regex = ['^\s*\x{276F}?\s*Decline\b'] },
+ ] },
+]
+
+[[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 = "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"] },
+]
+# Claude marks the selected option with "❯", so every option branch has to allow
+# that prefix. Numbered branches that omit it only match options the cursor has
+# moved away from, which left the resting layout below unmatched here (#2650):
+# ❯ 1. Yes / 2. Yes, and don't ask again for: <cmd> / 3. No
+# Cover both the two-option and "don't ask again" three-option shapes so this
+# rule, not the narrower generic_permission_prompt, claims Bash approvals.
+all = [
+ { any = [
+ { line_regex = ['(?i)^\s*❯?\s*yes\b'] },
+ { line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] },
+ { line_regex = ['(?i)^\s*❯?\s*2\.\s*yes\b'] },
+ { line_regex = ['(?i)^\s*❯?\s*2\.\s*no\b'] },
+ { line_regex = ['(?i)^\s*❯?\s*3\.\s*no\b'] },
+ ] },
+]
+
+[[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 = "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*$'] },
+]
+
+[[rules]]
+id = "osc_title_idle"
+state = "idle"
+priority = 250
+region = "osc_title"
+visible_idle = true
+regex = ['^\x{2733} ']
+
+[[rules]]
+id = "osc_progress_idle"
+state = "idle"
+priority = 250
+region = "osc_progress"
+regex = ['^4;0']
diff --git a/crates/detect-core/src/manifests/cline.toml b/crates/detect-core/src/manifests/cline.toml
new file mode 100644
index 0000000..2d31408
--- /dev/null
+++ b/crates/detect-core/src/manifests/cline.toml
@@ -0,0 +1,63 @@
+id = "cline"
+version = "2026.09.11.1"
+min_engine_version = 1
+updated_at = "2026-09-11T00: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 = "inline_tool_permission"
+state = "blocked"
+priority = 300
+region = "bottom_non_empty_lines(16)"
+visible_blocker = true
+contains = ["Cline needs permission", "Approve tool call?", "[y] Approve", "[n] Deny"]
+
+[[rules]]
+id = "inline_question"
+state = "blocked"
+priority = 300
+region = "bottom_non_empty_lines(24)"
+visible_blocker = true
+line_regex = ['^\s*Cline is asking a question\s*$', '^\s*>\s+\S']
+contains = ["(Tab)", "Shift+Tab"]
+
+[[rules]]
+id = "active_turn"
+state = "working"
+priority = 200
+region = "bottom_non_empty_lines(20)"
+visible_working = true
+any = [
+ { line_regex = ['^\s*[\x{2801}-\x{28FF}]\s+\S'] },
+ { contains = ["Thinking... (esc to cancel)"] },
+]
+
+[[rules]]
+id = "composer_idle"
+state = "idle"
+priority = 100
+region = "bottom_non_empty_lines(12)"
+visible_idle = true
+line_regex = ['^\s*❯(?:\s.*)?$']
+contains = ["─", "(Tab)", "Shift+Tab"]
+
+[[rules]]
+id = "default_cline_working"
+state = "working"
+priority = -10
+region = "whole_recent"
+visible_working = true
+regex = ['(?s).+']
diff --git a/crates/detect-core/src/manifests/codex.toml b/crates/detect-core/src/manifests/codex.toml
new file mode 100644
index 0000000..d35820c
--- /dev/null
+++ b/crates/detect-core/src/manifests/codex.toml
@@ -0,0 +1,129 @@
+id = "codex"
+version = "2026.10.11.1"
+min_engine_version = 3
+updated_at = "2026-10-11T00:00:00Z"
+
+[[rules]]
+id = "osc_title_blocked"
+state = "blocked"
+priority = 1100
+region = "osc_title"
+visible_blocker = true
+contains = ["Action Required"]
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 1050
+region = "osc_title"
+visible_working = true
+regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)']
+
+[[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 = "trust_directory"
+state = "blocked"
+priority = 950
+region = "top_non_empty_lines(20)"
+visible_blocker = true
+all = [
+ { any = [
+ { regex = ['\A> You are in [^\r\n]+(?:\r?\n|$)'] },
+ { contains = ["Folder access"] },
+ ] },
+ { any = [
+ { regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] },
+ { all = [
+ { contains = ["Trust this folder?", "Codex can read, edit, and run files here"] },
+ { any = [
+ { contains = ["Trust and continue"] },
+ { contains = ["enter continue"] },
+ ] },
+ ] },
+ ] },
+]
+
+[[rules]]
+id = "startup_update"
+state = "blocked"
+priority = 950
+region = "bottom_non_empty_lines(20)"
+visible_blocker = true
+# Keep the legacy dialog; the shared picker (0.159+) has a different heading
+# and footer. Choices can scroll out of a short viewport, but both gates must
+# remain visible. The passive update history notice has neither picker footer.
+any = [
+ { all = [
+ { contains = ["Update available!", "Update now"] },
+ { regex = ['Skip\s+until\s+next\s+version', 'Press enter to continue\s*\z'] },
+ ] },
+ { all = [
+ { regex = ['(?m)^\s*Update\s+available\s+·'] },
+ { regex = ['enter\s+continue\s+·\s+esc\s+skip\s*\z'] },
+ ] },
+]
+
+[[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?"] },
+ { contains = ["All Results", "Filesystem Only", "Plugins"] },
+]
+
+[[rules]]
+id = "weak_blocker"
+state = "blocked"
+priority = 600
+region = "whole_recent_without_current_prompt_marker"
+# Sparkles can replace the space after ›. A later response marker makes that prompt stale.
+not = [{ regex = ['(?m)^›[⠁⠂⠄⠈⠐⠠⡀⢀][^\n]*(?:\n(?:[^•■✗✓\n][^\n]*)?)*\z'] }]
+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 = "screen_working_fallback"
+state = "working"
+priority = 500
+region = "before_current_prompt_marker"
+visible_working = true
+# Support animated and reduced-motion status, including dynamic activity labels.
+# The interrupt hint can be remapped, unbound, or hidden, and queued inputs can
+# sit below the status. Require the live timer suffix with no later response.
+any = [{ contains = [" to interrupt)"] }, { contains = ["s)"] }]
+regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s(?: • [^\r\n]+? to interrupt)?\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+[^\r\n]+?\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|›[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z']
+# A failed reconnect keeps its final elapsed timer but is no longer working.
+not = [{ line_regex = ['^(?:[•◦][ \t]+)?Reconnect failed — check the endpoint, then relaunch \([0-9hms ]+\)$'] }]
+
+[[rules]]
+id = "osc_title_idle"
+state = "idle"
+priority = 100
+region = "osc_title"
+visible_idle = true
+regex = ['\S']
+not = [
+ { regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] },
+ { contains = ["Action Required"] },
+]
diff --git a/crates/detect-core/src/manifests/cursor.toml b/crates/detect-core/src/manifests/cursor.toml
new file mode 100644
index 0000000..ee03e6d
--- /dev/null
+++ b/crates/detect-core/src/manifests/cursor.toml
@@ -0,0 +1,57 @@
+id = "cursor"
+version = "2026.08.03.1"
+min_engine_version = 1
+updated_at = "2026-08-03T01:08:04Z"
+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*(?:→\s*)?run .*\(y\)'] },
+]
+
+[[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/crates/detect-core/src/manifests/devin.toml b/crates/detect-core/src/manifests/devin.toml
new file mode 100644
index 0000000..c9564f7
--- /dev/null
+++ b/crates/detect-core/src/manifests/devin.toml
@@ -0,0 +1,86 @@
+id = "devin"
+version = "2026.06.15.1"
+min_engine_version = 1
+updated_at = "2026-06-15T00:00:00Z"
+aliases = ["devin-cli", "devin cli"]
+
+[[rules]]
+id = "workspace_trust_prompt"
+state = "blocked"
+priority = 300
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = [
+ "do you trust the authors of this directory?",
+ "with untrusted content.",
+ "yes, trust ",
+]
+
+[[rules]]
+id = "permission_prompt"
+state = "blocked"
+priority = 290
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = ["approve once", "select", "confirm", "esc cancel"]
+
+[[rules]]
+id = "running_tools_footer"
+state = "working"
+priority = 200
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+contains = ["running tools", "esc to interrupt"]
+not = [
+ { contains = ["approve once", "esc cancel"] },
+]
+
+[[rules]]
+id = "guide_while_working"
+state = "working"
+priority = 190
+region = "bottom_non_empty_lines(6)"
+visible_working = true
+contains = ["guide devin while it works"]
+not = [
+ { contains = ["approve once", "esc cancel"] },
+]
+
+[[rules]]
+id = "tool_reading_timeout"
+state = "working"
+priority = 180
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+contains = ["reading shell ", "timeout:"]
+not = [
+ { contains = ["approve once", "esc cancel"] },
+]
+
+[[rules]]
+id = "welcome_prompt_footer"
+state = "idle"
+priority = 120
+region = "bottom_non_empty_lines(8)"
+visible_idle = true
+contains = ["ask devin to build", "features, fix bugs", "your code"]
+line_regex = ['^\s*❭ Ask Devin to build']
+not = [
+ { contains = ["approve once", "esc cancel"] },
+ { contains = ["running tools", "esc to interrupt"] },
+ { contains = ["guide devin while it works"] },
+]
+
+[[rules]]
+id = "live_prompt_footer"
+state = "idle"
+priority = 100
+region = "bottom_non_empty_lines(6)"
+visible_idle = true
+contains = ["context:"]
+line_regex = ['^\s*❭']
+not = [
+ { contains = ["approve once", "esc cancel"] },
+ { contains = ["running tools", "esc to interrupt"] },
+ { contains = ["guide devin while it works"] },
+]
diff --git a/crates/detect-core/src/manifests/droid.toml b/crates/detect-core/src/manifests/droid.toml
new file mode 100644
index 0000000..c41d71b
--- /dev/null
+++ b/crates/detect-core/src/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/crates/detect-core/src/manifests/gemini.toml b/crates/detect-core/src/manifests/gemini.toml
new file mode 100644
index 0000000..9d7a28e
--- /dev/null
+++ b/crates/detect-core/src/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/crates/detect-core/src/manifests/github-copilot.toml b/crates/detect-core/src/manifests/github-copilot.toml
new file mode 100644
index 0000000..57fafdd
--- /dev/null
+++ b/crates/detect-core/src/manifests/github-copilot.toml
@@ -0,0 +1,45 @@
+id = "copilot"
+version = "2026.08.29.1"
+min_engine_version = 1
+updated_at = "2026-08-29T00:00:00Z"
+aliases = ["github-copilot", "ghcs"]
+
+[[rules]]
+id = "selection_blocker"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+all = [
+ { any = [
+ { contains = ["esc to cancel"] },
+ { contains = ["esc cancel"] },
+ ] },
+ { any = [
+ { contains = ["enter to select"] },
+ { contains = ["enter to confirm"] },
+ { contains = ["enter to submit"] },
+ { contains = ["enter accept"] },
+ ] },
+]
+
+[[rules]]
+id = "background_agents_working"
+state = "working"
+priority = 110
+region = "bottom_non_empty_lines(6)"
+visible_working = true
+line_regex = ['^\s*◎\s+Waiting for background agents(?:\s|·|$)']
+
+[[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"] },
+ { contains = ["esc interrupt"] }
+]
diff --git a/crates/detect-core/src/manifests/grok.toml b/crates/detect-core/src/manifests/grok.toml
new file mode 100644
index 0000000..bc37bc4
--- /dev/null
+++ b/crates/detect-core/src/manifests/grok.toml
@@ -0,0 +1,161 @@
+id = "grok"
+version = "2026.10.05.1"
+min_engine_version = 3
+updated_at = "2026-10-05T00:00:00Z"
+aliases = ["grok-build"]
+
+# Evidence: Grok Build 0.2.101 source and 1.0.34 live pane reads.
+#
+# Grok emits OSC 0 titles by default. Idle is "grok" or
+# "<session> - grok". During a turn, the configured title gains a braille
+# spinner and activity text. Permission prompts add "⚠ Action Required";
+# that prefix blinks while the terminal is unfocused, so visible blocker
+# rules outrank the title spinner rule.
+#
+# Grok also emits OSC 9;4 progress on supported terminals. Herdr retains the
+# payload after "9;": "4;1;-1" while busy and "4;0;0" when idle.
+#
+# Working turns render one live status line directly above the prompt box:
+# "⠧ Waiting on subagent… 2.8s 13s ⇣29.7k [stop]"
+# "⠴ Explore /tmp/… + 1 more… 5.6s 19s ⇣29.7k [stop]"
+# with a braille spinner and a trailing [stop] chip, plus an Esc:cancel
+# footer hint. Permission prompts and ask-user-question dialogs replace the
+# spinner with "◆" and draw a "┃"-guttered option list:
+# "┃ 2 (○) Yes, proceed"
+# "┃ z (○) Type your answer here"
+# with footer hints "1/3:select │ Ctrl+o:yolo │ Ctrl+c:cancel" (permission)
+# or "Esc:unselect │ Tab:scrollback │ Shift+x:dismiss" (question dialog).
+# Idle footers end with "Ctrl+.:shortcuts" and never contain "Esc:cancel".
+# The startup splash draws its logo with braille characters, so working
+# rules must anchor on the [stop] chip, not on a bare spinner glyph.
+
+[[rules]]
+id = "osc_title_blocked"
+state = "blocked"
+priority = 1300
+region = "osc_title"
+visible_blocker = true
+contains = ["Action Required"]
+
+[[rules]]
+id = "option_dialog_blocked"
+state = "blocked"
+priority = 1200
+region = "whole_recent"
+visible_blocker = true
+line_regex = ['^\s*┃\s+[0-9a-z]+\s+\([●○]\)\s']
+
+[[rules]]
+id = "permission_hints_blocked"
+state = "blocked"
+priority = 1190
+region = "bottom_non_empty_lines(2)"
+visible_blocker = true
+contains = [":select", "ctrl+o:yolo", "ctrl+c:cancel"]
+
+[[rules]]
+id = "question_dialog_hints_blocked"
+state = "blocked"
+priority = 1185
+region = "bottom_non_empty_lines(2)"
+visible_blocker = true
+contains = ["tab:scrollback", "shift+x:dismiss"]
+
+# Pre-0.2.x permission UI kept for older Grok Build releases.
+[[rules]]
+id = "permission_scope_selector"
+state = "blocked"
+priority = 1180
+region = "whole_recent"
+visible_blocker = true
+contains = ["yes, proceed", "no, reject"]
+any = [
+ { contains = ["use ← → to choose permission whitelist scope"] },
+ { contains = ["←/→:scope"] },
+]
+
+# Background commands, monitors, and subagents left running after Grok returns
+# to its prompt do not count as working, matching Claude and agy (#1217, #4735).
+
+[[rules]]
+id = "osc_progress_working"
+state = "working"
+priority = 1150
+region = "osc_progress"
+visible_working = true
+regex = ['^4;1;-1$']
+
+[[rules]]
+id = "osc_title_idle"
+state = "idle"
+priority = 1100
+region = "osc_title"
+visible_idle = true
+regex = ['(?:^| - )grok$']
+not = [
+ { regex = ['[\x{2800}-\x{28FF}]'] },
+]
+
+# Title items are configurable; omitting "grok" does not imply activity.
+# Require a spinner, with OSC progress covering titles that omit it.
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 1000
+region = "osc_title"
+visible_working = true
+regex = ['(?:^|\s)[\x{2801}-\x{28FF}](?:\s|$)']
+
+[[rules]]
+id = "osc_progress_idle"
+state = "idle"
+priority = 950
+region = "osc_progress"
+visible_idle = true
+regex = ['^4;0;0$']
+
+# Visible activity outranks idle OSC signals: disabling title updates can
+# leave the startup "grok" title unchanged throughout a turn.
+[[rules]]
+id = "spinner_status_working"
+state = "working"
+priority = 1160
+region = "whole_recent"
+visible_working = true
+line_regex = ['^\s*[\x{2801}-\x{28FF}]\s.*\[stop\]\s*$']
+
+[[rules]]
+id = "esc_cancel_hints_working"
+state = "working"
+priority = 1155
+region = "bottom_non_empty_lines(2)"
+visible_working = true
+contains = ["ctrl+.:shortcuts"]
+any = [
+ { contains = ["esc:cancel"] },
+ { contains = ["ctrl+c:cancel"] },
+]
+
+# Pre-0.2.x working chrome kept for older Grok Build releases.
+[[rules]]
+id = "waiting_tool_working"
+state = "working"
+priority = 1140
+region = "whole_recent"
+visible_working = true
+any = [
+ { all = [{ contains = ["ctrl+c:cancel", "ctrl+enter:interject"] }, { contains = ["waiting"] }] },
+ { line_regex = ['^\s*[\x{2801}-\x{28FF}]\s+(Run|Read|Search|List)\b'] },
+]
+
+[[rules]]
+id = "prompt_hints_idle"
+state = "idle"
+priority = 100
+region = "bottom_non_empty_lines(2)"
+visible_idle = true
+contains = ["ctrl+.:shortcuts"]
+not = [
+ { contains = ["esc:cancel"] },
+ { contains = ["ctrl+c:cancel"] },
+]
diff --git a/crates/detect-core/src/manifests/hermes.toml b/crates/detect-core/src/manifests/hermes.toml
new file mode 100644
index 0000000..1754218
--- /dev/null
+++ b/crates/detect-core/src/manifests/hermes.toml
@@ -0,0 +1,102 @@
+id = "hermes"
+version = "2026.07.24.1"
+min_engine_version = 2
+updated_at = "2026-07-24T19:12:54Z"
+aliases = ["hermes-agent"]
+
+[[rules]]
+id = "osc_title_blocked"
+state = "blocked"
+priority = 1100
+region = "osc_title"
+visible_blocker = true
+regex = ['^⚠[\u{fe0e}\u{fe0f}]?(?:\s|$)']
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 1050
+region = "osc_title"
+visible_working = true
+regex = ['^⏳[\u{fe0e}\u{fe0f}]?(?:\s|$)']
+
+[[rules]]
+id = "dangerous_command_approval"
+state = "blocked"
+priority = 900
+region = "bottom_non_empty_lines(14)"
+visible_blocker = true
+any = [
+ { contains = ["dangerous"] },
+ { contains = ["approval"] },
+ { contains = ["allow once", "deny"] },
+ { line_regex = ['(?i)^\s*[▸>]?\s*1\.\s*allow'] },
+]
+all = [
+ { any = [{ contains = ["enter confirm"] }, { contains = ["enter to confirm"] }, { contains = ["↑/↓ to select"] }, { contains = ["show full command"] }] },
+]
+
+[[rules]]
+id = "clarification_prompt"
+state = "blocked"
+priority = 900
+region = "bottom_non_empty_lines(14)"
+visible_blocker = true
+any = [
+ { contains = ["hermes needs your"] },
+ { line_regex = ['^\s*ask\s+\S'] },
+ { contains = ["type your answer"] },
+]
+all = [
+ { any = [{ contains = ["enter confirm"] }, { contains = ["enter to confirm"] }, { contains = ["enter send"] }, { contains = ["press enter"] }, { contains = ["↑/↓ select"] }, { contains = ["↑/↓ to select"] }, { contains = ["other (type"] }] },
+]
+
+[[rules]]
+id = "credential_prompt"
+state = "blocked"
+priority = 900
+region = "bottom_non_empty_lines(14)"
+visible_blocker = true
+any = [
+ { contains = ["sudo password"] },
+ { contains = ["skill setup"] },
+ { contains = ["🔑", "for "] },
+]
+
+[[rules]]
+id = "confirmation_prompt"
+state = "blocked"
+priority = 900
+region = "bottom_non_empty_lines(14)"
+visible_blocker = true
+all = [
+ { any = [{ contains = ["approve once", "cancel"] }, { contains = ["start a new session", "keep going"] }] },
+ { any = [{ contains = ["enter to confirm"] }, { contains = ["enter confirm"] }, { contains = ["type 1/2/3"] }, { contains = ["y/n quick"] }] },
+]
+
+[[rules]]
+id = "interrupt_status_working"
+state = "working"
+priority = 950
+region = "bottom_non_empty_lines(5)"
+visible_working = true
+any = [
+ { contains = ["msg=interrupt"] },
+ { contains = ["ctrl+c to interrupt"] },
+]
+
+[[rules]]
+id = "classic_cancel_working"
+state = "working"
+priority = 500
+region = "bottom_non_empty_lines(5)"
+visible_working = true
+contains = ["ctrl+c cancel"]
+
+[[rules]]
+id = "osc_title_idle"
+state = "idle"
+priority = 100
+region = "osc_title"
+visible_idle = true
+regex = ['^✓[\u{fe0e}\u{fe0f}]?(?:\s|$)']
diff --git a/crates/detect-core/src/manifests/kilo.toml b/crates/detect-core/src/manifests/kilo.toml
new file mode 100644
index 0000000..4ef004e
--- /dev/null
+++ b/crates/detect-core/src/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/crates/detect-core/src/manifests/kimi.toml b/crates/detect-core/src/manifests/kimi.toml
new file mode 100644
index 0000000..b4d0100
--- /dev/null
+++ b/crates/detect-core/src/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/crates/detect-core/src/manifests/kiro.toml b/crates/detect-core/src/manifests/kiro.toml
new file mode 100644
index 0000000..9d50f93
--- /dev/null
+++ b/crates/detect-core/src/manifests/kiro.toml
@@ -0,0 +1,97 @@
+id = "kiro"
+version = "2026.09.19.1"
+min_engine_version = 2
+updated_at = "2026-09-19T00:00:00Z"
+aliases = ["kiro-cli"]
+
+[[rules]]
+id = "live_prompt_idle"
+state = "idle"
+priority = 1100
+region = "bottom_non_empty_lines(4)"
+visible_idle = true
+line_regex = ['(?i)^\s*[>›]\s*ask a question or describe a task(?:\s+(?:enter|↵))?\s*$']
+
+[[rules]]
+id = "tool_approval"
+state = "blocked"
+priority = 1050
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+any = [
+ { all = [
+ { regex = ['(?is)(?:^|\n)\s*esc\s+to\s+close\b(?:[^\n]*\bto\s+navigate\b[^\n]*|.*\bto\s+navigate\b.*\bto\s+select\b.*\btab\s+to\s+edit|[^\n]*\benter\s+to\s+see\s+more\s+options)\s*\z'] },
+ { any = [
+ { line_regex = [
+ '(?i)^\s*[>❯]\s*(?:allow|always allow|deny|always deny)\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?allow\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?always allow\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?deny\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?always deny\s*$',
+ ] },
+ { line_regex = [
+ '(?i)^\s*[>❯]\s*(?:yes, single permission|trust, always allow in this session|no \(tab to edit\))\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?yes, single permission\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?trust, always allow in this session\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?no \(tab to edit\)\s*$',
+ ] },
+ { line_regex = [
+ '(?i)^\s*[>❯]\s*(?:trust\b.*|entire tool)\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?(?:trust )?entire tool(?:\s+\([^\n]*\))?(?:\s+(?:session|workspace|always))?\s*$',
+ ] },
+ ] },
+ ] },
+]
+
+[[rules]]
+id = "tool_approval_edit"
+state = "blocked"
+priority = 1045
+region = "whole_recent"
+visible_blocker = true
+regex = ['(?im)(?:^|\n)[ \t]*[-─]+[ \t]*\n[^\n]*requires\s+approval\s*[·.]\s*modify\s+request[ \t]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*?[ \t]*[>›][ \t]*[^\n]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*[ \t]*[-─]+[ \t]*\n[ \t]*esc[ \t]+to[ \t]+close[ \t]*\n?\z']
+
+[[rules]]
+id = "crew_approval"
+state = "blocked"
+priority = 1040
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = [
+ "tool approval",
+ "approve all pending",
+ "configure individually (agent monitor)",
+ "exit (cancel subagents)",
+]
+
+[[rules]]
+id = "question_panel"
+state = "blocked"
+priority = 1030
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = ["to navigate", "to submit", "esc to cancel"]
+
+[[rules]]
+id = "live_working_footer"
+state = "working"
+priority = 950
+region = "bottom_non_empty_lines(4)"
+visible_working = true
+contains = ["kiro is working", "type to steer", "ctrl+s to queue"]
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 900
+region = "osc_title"
+visible_working = true
+regex = ['(?i)^[◐◓◑◒/|\\-]\s+kiro:']
+
+[[rules]]
+id = "osc_progress_working"
+state = "working"
+priority = 890
+region = "osc_progress"
+visible_working = true
+regex = ['^4;3;?$']
diff --git a/crates/detect-core/src/manifests/letta.toml b/crates/detect-core/src/manifests/letta.toml
new file mode 100644
index 0000000..e27e2aa
--- /dev/null
+++ b/crates/detect-core/src/manifests/letta.toml
@@ -0,0 +1,95 @@
+id = "letta"
+version = "2026.08.24.1"
+min_engine_version = 3
+updated_at = "2026-08-24T00:00:00Z"
+aliases = ["letta-code", "letta code"]
+
+# Letta keeps completed tool rows and spinners in the transcript. A standalone
+# screen spinner is not working evidence. Prefer its live OSC title/progress
+# signals, with visible status chrome and running tool rows as fallbacks.
+[[rules]]
+id = "osc_progress_blocked"
+state = "blocked"
+priority = 1400
+region = "osc_progress"
+visible_blocker = true
+regex = ['^4;3(?:;|$)']
+
+[[rules]]
+id = "osc_title_blocked"
+state = "blocked"
+priority = 1300
+region = "osc_title"
+visible_blocker = true
+regex = ['^\[ [!.] \] Action Required(?: \| |$)']
+
+[[rules]]
+id = "command_approval"
+state = "blocked"
+priority = 1200
+region = "bottom_non_empty_lines(20)"
+visible_blocker = true
+contains = ["Run this command?", "Enter to select · Esc to cancel"]
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 900
+region = "osc_title"
+visible_working = true
+regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)']
+
+[[rules]]
+id = "active_status"
+state = "working"
+priority = 850
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$']
+
+[[rules]]
+id = "running_tool"
+state = "working"
+priority = 800
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$']
+
+[[rules]]
+id = "profile_selector"
+state = "unknown"
+priority = 700
+region = "bottom_non_empty_lines(12)"
+contains = ["Create a new agent (--new)", "Enter select · Esc exit"]
+
+[[rules]]
+id = "composer_input"
+state = "unknown"
+priority = 150
+region = "bottom_non_empty_lines(8)"
+line_regex = ['^\s*›\s+\S.*$']
+not = [
+ { line_regex = ['^\s*›\s+Try\s+"'] },
+]
+
+[[rules]]
+id = "composer_idle"
+state = "idle"
+priority = 100
+region = "bottom_non_empty_lines(8)"
+visible_idle = true
+any = [
+ { line_regex = ['^\s*›\s*$'] },
+ { line_regex = ['^\s*›\s+Try\s+"'] },
+]
+not = [
+ { line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] },
+ { line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] },
+]
+
+[[rules]]
+id = "no_live_state_evidence"
+state = "unknown"
+priority = 0
+region = "whole_recent"
+regex = ['(?s)^.*$']
diff --git a/crates/detect-core/src/manifests/maki.toml b/crates/detect-core/src/manifests/maki.toml
new file mode 100644
index 0000000..5c58404
--- /dev/null
+++ b/crates/detect-core/src/manifests/maki.toml
@@ -0,0 +1,68 @@
+id = "maki"
+version = "2026.07.09.2"
+min_engine_version = 1
+updated_at = "2026-07-09T00:00:00Z"
+
+# Maki renders a persistent one-line status bar on the bottom row. It starts
+# with the mode label "[BUILD]", "[PLAN]", or "[BASH]" when idle and gets a
+# leading braille spinner cell while the agent is streaming. Permission
+# requests and the plan-complete form replace the input box above the status
+# bar. Maki does not set OSC title or OSC 9;4 progress.
+
+[[rules]]
+id = "permission_prompt"
+state = "blocked"
+priority = 980
+region = "whole_recent"
+visible_blocker = true
+contains = ["permission required"]
+any = [
+ { contains = ["y allow", "n deny"] },
+ { contains = ["confirm allow"] },
+ { contains = ["confirm deny"] },
+ { contains = ["enter deny", "esc cancel"] },
+]
+
+[[rules]]
+id = "plan_complete_form"
+state = "blocked"
+priority = 970
+region = "whole_recent"
+visible_blocker = true
+contains = ["plan complete", "enter confirm"]
+any = [
+ { contains = ["space toggle parallel"] },
+ { contains = ["edit plan"] },
+]
+
+[[rules]]
+id = "status_bar_spinner_working"
+state = "working"
+priority = 900
+region = "bottom_non_empty_lines(1)"
+visible_working = true
+line_regex = ['^( [\x{2800}-\x{28FF}]){1,2} \[(BUILD|PLAN|BASH)\]']
+
+[[rules]]
+id = "status_bar_idle"
+state = "idle"
+priority = 850
+region = "bottom_non_empty_lines(1)"
+visible_idle = true
+line_regex = ['^ \[(BUILD|PLAN|BASH)\]']
+
+# On narrow panes the right side of the status bar overwrites the mode label,
+# so fall back to the prompt chevron above the input box border. The not-gates
+# keep this from matching the streaming placeholder or a status bar that still
+# shows the spinner.
+[[rules]]
+id = "prompt_box_idle"
+state = "idle"
+priority = 840
+region = "bottom_non_empty_lines(3)"
+visible_idle = true
+line_regex = ['^❯ ']
+not = [
+ { contains = ["queue another prompt"] },
+ { line_regex = ['^( [\x{2800}-\x{28FF}]){1,2} '] },
+]
diff --git a/crates/detect-core/src/manifests/muse.toml b/crates/detect-core/src/manifests/muse.toml
new file mode 100644
index 0000000..5081842
--- /dev/null
+++ b/crates/detect-core/src/manifests/muse.toml
@@ -0,0 +1,113 @@
+id = "muse"
+version = "2026.08.26.1"
+min_engine_version = 2
+updated_at = "2026-08-26T00:00:00Z"
+aliases = ["muse-code", "muse-cli"]
+
+# Evidence: live bottom-buffer captures from Muse Code 0.2.1 in Herdr, using a local
+# deterministic Responses provider so each UI state could be held and inspected.
+#
+# Idle has a `⟩` prompt and `model · effort · cwd` footer. Active turns show
+# `◆ Working (... · esc to interrupt)` or another activity label with the same interrupt hint.
+#
+# Structured questions show two co-occurring footer controls:
+# Enter to select · ↑/↓ to move · Tab for an optional note · Esc to interrupt
+# Multi-select uses `Enter to toggle` instead. The paired controls distinguish a real picker
+# from ordinary transcript text that happens to mention one action.
+#
+# First launch in an untrusted directory shows `Do you trust this workspace?` together with
+# `Trust and continue`. This is a real blocker. User-opened `/theme` and `/skills` menus are
+# not blockers; their paired footer controls identify overlays whose prior state must be kept.
+#
+# Muse 0.2.1 command approval shows `Allow this stage once` together with
+# `Always allow in this workspace`. Muse 0.1 used `Allow once` with
+# `Allow for this session`. Network approval shows `Yes, proceed` together with
+# `Yes, don't ask again this session`. Each approval rule requires a pair because Muse can
+# emit any one of these phrases as ordinary assistant text after a completed turn.
+
+[[rules]]
+id = "workspace_trust_blocked"
+state = "blocked"
+priority = 970
+region = "bottom_non_empty_lines(12)"
+visible_blocker = true
+contains = ["Do you trust this workspace?"]
+any = [
+ { contains = ["Trust and continue"] },
+ { contains = ["Use Up/Down"] },
+]
+
+[[rules]]
+id = "pick_request_blocked"
+state = "blocked"
+priority = 950
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+any = [
+ { contains = ["Enter to select", "Tab for an optional note"] },
+ { contains = ["Enter to toggle", "Esc to interrupt"] },
+]
+
+[[rules]]
+id = "menu_overlay"
+state = "unknown"
+priority = 940
+region = "bottom_non_empty_lines(8)"
+skip_state_update = true
+any = [
+ { contains = ["enter confirm", "esc go back"] },
+ { contains = ["enter save", "esc go back"] },
+ { contains = ["space toggle", "esc close", "type filter"] },
+]
+
+[[rules]]
+id = "working_esc_interrupt"
+state = "working"
+priority = 900
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+contains = ["esc to interrupt"]
+not = [
+ { contains = ["Enter to select", "Tab for an optional note"] },
+ { contains = ["Enter to toggle", "Esc to interrupt"] },
+]
+
+[[rules]]
+id = "blocked_approval"
+state = "blocked"
+priority = 850
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+any = [
+ { contains = ["Allow this stage once", "Always allow in this workspace"] },
+ { contains = ["Allow once", "Allow for this session"] },
+ { contains = ["Yes, proceed", "Yes, don't ask again this session"] },
+]
+
+[[rules]]
+id = "idle_prompt"
+state = "idle"
+priority = 700
+region = "bottom_non_empty_lines(5)"
+visible_idle = true
+any = [
+ { line_regex = ['^\s*⟩\s*$'] },
+ { line_regex = ['^\s*⟩\s+\S'] },
+]
+not = [
+ { contains = ["esc to interrupt"] },
+ { contains = ["Enter to select", "Tab for an optional note"] },
+ { contains = ["Enter to toggle", "Esc to interrupt"] },
+ { contains = ["enter confirm", "esc go back"] },
+ { contains = ["enter save", "esc go back"] },
+ { contains = ["space toggle", "esc close", "type filter"] },
+]
+
+[[rules]]
+id = "idle_status_fallback"
+state = "idle"
+priority = 500
+region = "bottom_non_empty_lines(3)"
+visible_idle = true
+line_regex = ['^\s*\S+ · (none|minimal|low|medium|high|xhigh|ultra) · ']
+not = [{ contains = ["esc to interrupt"] }]
diff --git a/crates/detect-core/src/manifests/opencode.toml b/crates/detect-core/src/manifests/opencode.toml
new file mode 100644
index 0000000..5245238
--- /dev/null
+++ b/crates/detect-core/src/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/crates/detect-core/src/manifests/pi.toml b/crates/detect-core/src/manifests/pi.toml
new file mode 100644
index 0000000..5ae51a8
--- /dev/null
+++ b/crates/detect-core/src/manifests/pi.toml
@@ -0,0 +1,27 @@
+id = "pi"
+version = "2026.10.01.1"
+min_engine_version = 1
+updated_at = "2026-10-01T00:00:00Z"
+aliases = ["herdr:pi"]
+
+[[rules]]
+id = "working_literal"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+any = [
+ { contains = ["Working..."] },
+ { line_regex = ['^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working$'] },
+]
+
+[[rules]]
+id = "working_border"
+state = "working"
+priority = 100
+region = "bottom_non_empty_lines(12)"
+visible_working = true
+any = [
+ { line_regex = ['^── [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working ─+$'] },
+ { line_regex = ['^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working$'] },
+]
diff --git a/crates/detect-core/src/manifests/qodercli.toml b/crates/detect-core/src/manifests/qodercli.toml
new file mode 100644
index 0000000..51ca805
--- /dev/null
+++ b/crates/detect-core/src/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/crates/detect-core/src/manifests/qwen.toml b/crates/detect-core/src/manifests/qwen.toml
new file mode 100644
index 0000000..bbed237
--- /dev/null
+++ b/crates/detect-core/src/manifests/qwen.toml
@@ -0,0 +1,119 @@
+id = "qwen"
+version = "2026.08.14.1"
+min_engine_version = 2
+updated_at = "2026-08-14T00:00:00Z"
+aliases = ["qwen-code", "qwen code"]
+
+# Qwen Code keeps its composer visible while responding, so the prompt box is
+# not idle evidence by itself. Its status-prefixed terminal titles are the
+# primary locale-independent signals when ui.showStatusInTitle is enabled.
+# Exact screen fallbacks cover built-in locales and narrow terminals. OSC 9;4
+# is supplemental because Qwen emits it only while a tool is executing and
+# only in terminals that advertise progress support.
+
+[[rules]]
+id = "osc_title_blocked"
+state = "blocked"
+priority = 1200
+region = "osc_title"
+visible_blocker = true
+regex = ['^\x{2733}\x{FE0E}? ']
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 1100
+region = "osc_title"
+visible_working = true
+regex = ['^\x{25D0}\x{FE0E}? ']
+
+[[rules]]
+id = "waiting_for_confirmation"
+state = "blocked"
+priority = 1000
+region = "bottom_non_empty_lines(20)"
+visible_blocker = true
+line_regex = ['^\s*⠏\s+.*\.\.\.\s*$']
+any = [
+ { contains = ["Waiting for user confirmation..."] },
+ { contains = ["等待用户确认..."] },
+ { contains = ["等待用戶確認..."] },
+ { contains = ["Warten auf Benutzerbestätigung..."] },
+ { contains = ["En attente de la confirmation de l'utilisateur..."] },
+ { contains = ["ユーザーの確認を待っています..."] },
+ { contains = ["Aguardando confirmação do usuário..."] },
+ { contains = ["Ожидание подтверждения от пользователя..."] },
+ { contains = ["Esperant la confirmació de l'usuari..."] },
+]
+
+[[rules]]
+id = "tool_confirmation"
+state = "blocked"
+priority = 990
+region = "bottom_non_empty_lines(20)"
+visible_blocker = true
+contains = ["yes, allow once"]
+any = [
+ { contains = ["apply this change?"] },
+ { contains = ["allow execution of:"] },
+ { contains = ["allow execution of mcp tool"] },
+ { contains = ["do you want to proceed?"] },
+ { contains = ["shell command execution"] },
+]
+
+[[rules]]
+id = "question_dialog"
+state = "blocked"
+priority = 980
+region = "bottom_non_empty_lines(20)"
+visible_blocker = true
+line_regex = [
+ '^\s*[❯›]\s*(?:\[(?: |✓)\]\s*)?\d+\.\s+',
+ '^\s*↑/↓\s*:.*(?:Enter|Return)\s*:',
+]
+
+[[rules]]
+id = "folder_trust_dialog"
+state = "blocked"
+priority = 970
+region = "bottom_non_empty_lines(20)"
+visible_blocker = true
+contains = ["do you trust this folder?", "trust folder (", "don't trust (esc)"]
+
+[[rules]]
+id = "cancel_hint_working"
+state = "working"
+priority = 900
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+line_regex = ['^\s*(?:[⠁-⣿]|\.{1,2})\s+.*\(\d+(?:m(?:\s+\d+s)?|s).*\s·\sesc to cancel\)\s*$']
+
+[[rules]]
+id = "narrow_cancel_hint_working"
+state = "working"
+priority = 890
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+line_regex = ['^\s*\(\d+(?:m(?:\s+\d+s)?|s)\s·\sesc to cancel\)\s*$']
+
+[[rules]]
+id = "osc_tool_progress_working"
+state = "working"
+priority = 850
+region = "osc_progress"
+visible_working = true
+regex = ['^4;3(?:;|$)']
+
+[[rules]]
+id = "composer_idle"
+state = "idle"
+priority = 100
+region = "bottom_non_empty_lines(30)"
+visible_idle = true
+line_regex = ['^\s*>\s*(?:type\s*)?.*$']
+any = [
+ { contains = ["type your message"] },
+ { contains = ["your message or @path/to/file"] },
+ { contains = ["@path/to/file"] },
+ { contains = ["type", "mes", "sage", "@pat", "h/to", "/fil"] },
+]
diff --git a/crates/dinggerd/Cargo.toml b/crates/dinggerd/Cargo.toml
new file mode 100644
index 0000000..073b65e
--- /dev/null
+++ b/crates/dinggerd/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "dinggerd"
+version = "0.1.0"
+edition = "2021"
+description = "macOS agent watcher for tmux panes: notifications and status without owning a terminal"
+
+[dependencies]
+detect-core = { path = "../detect-core" }
+clap = { version = "4.5", default-features = false, features = ["std", "help", "usage", "error-context", "string", "derive"] }
+mac-notification-sys = "0.6"
+serde = { workspace = true }
+toml = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+
+[[bin]]
+name = "dingger"
+path = "src/main.rs"
diff --git a/crates/dinggerd/assets/done.mp3 b/crates/dinggerd/assets/done.mp3
new file mode 100644
index 0000000..753e734
--- /dev/null
+++ b/crates/dinggerd/assets/done.mp3
Binary files differ
diff --git a/crates/dinggerd/assets/request.mp3 b/crates/dinggerd/assets/request.mp3
new file mode 100644
index 0000000..fe4859e
--- /dev/null
+++ b/crates/dinggerd/assets/request.mp3
Binary files differ
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
new file mode 100644
index 0000000..f046732
--- /dev/null
+++ b/crates/dinggerd/src/main.rs
@@ -0,0 +1,100 @@
+//! dingger: agent state watching for tmux panes.
+
+mod manifest_update;
+mod notify;
+mod tmux;
+mod watcher;
+
+use clap::{Parser, Subcommand};
+use tracing_subscriber::EnvFilter;
+
+#[derive(Parser)]
+#[command(name = "dingger", about = "Agent state watching for tmux panes")]
+struct Cli {
+ #[command(subcommand)]
+ command: Command,
+}
+
+#[derive(Subcommand)]
+enum Command {
+ /// Watch all tmux panes and notify on agent state transitions.
+ Watch {
+ /// Poll interval in seconds.
+ #[arg(long, default_value_t = 1)]
+ interval_secs: u64,
+ /// Disable notification sounds.
+ #[arg(long)]
+ no_sounds: bool,
+ },
+ /// Print the current agent state of every tmux pane.
+ Status,
+ /// Refresh detection manifests from herdr's published catalog.
+ Manifests {
+ #[command(subcommand)]
+ command: ManifestCommand,
+ },
+}
+
+#[derive(Subcommand)]
+enum ManifestCommand {
+ /// Download newer manifests from herdr's published catalog into the
+ /// local remote store; bundled manifests are the offline fallback.
+ /// Restart `dingger watch` to pick them up.
+ Update,
+}
+
+fn main() {
+ tracing_subscriber::fmt()
+ .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
+ .init();
+
+ match Cli::parse().command {
+ Command::Watch {
+ interval_secs,
+ no_sounds,
+ } => {
+ let mut watcher = watcher::Watcher::new(interval_secs, !no_sounds);
+ if let Err(err) = watcher.run() {
+ eprintln!("dingger: {err}");
+ std::process::exit(1);
+ }
+ }
+ Command::Status => {
+ if let Err(err) = status() {
+ eprintln!("dingger: {err}");
+ std::process::exit(1);
+ }
+ }
+ Command::Manifests { command } => match command {
+ ManifestCommand::Update => {
+ if let Err(err) = manifest_update::update() {
+ eprintln!("dingger: {err}");
+ std::process::exit(1);
+ }
+ }
+ },
+ }
+}
+
+fn status() -> Result<(), String> {
+ let panes = tmux::list_panes()?;
+ if panes.is_empty() {
+ println!("no tmux panes");
+ return Ok(());
+ }
+ println!("{:<22} {:<10} {:<12} COMMAND", "TARGET", "AGENT", "STATE");
+ for pane in panes {
+ println!(
+ "{:<22} {:<10} {:<12} {}",
+ pane.target,
+ pane.agent.map(detect_core::agent_label).unwrap_or("-"),
+ if pane.written_state.is_empty() {
+ "-"
+ } else {
+ &pane.written_state
+ },
+ pane.current_command,
+ );
+ }
+ Ok(())
+}
diff --git a/crates/dinggerd/src/manifest_update.rs b/crates/dinggerd/src/manifest_update.rs
new file mode 100644
index 0000000..b18a153
--- /dev/null
+++ b/crates/dinggerd/src/manifest_update.rs
@@ -0,0 +1,79 @@
+//! Refresh detection manifests from herdr's published catalog
+//! (`https://herdr.dev/agent-detection/index.toml`). Bundled manifests remain
+//! the offline fallback; downloads land in dingger's state dir where the
+//! detection engine picks them up on the next watcher start.
+
+use std::process::Command;
+
+use serde::Deserialize;
+use tracing::info;
+
+use detect_core::manifest_update::{remote_manifest_path, remote_store_dir};
+use detect_core::{agent_label, Agent};
+
+const DEFAULT_CATALOG_URL: &str = "https://herdr.dev/agent-detection/index.toml";
+const CATALOG_URL_ENV: &str = "DINGGER_AGENT_DETECTION_MANIFEST_CATALOG_URL";
+
+#[derive(Deserialize)]
+struct ManifestCatalog {
+ #[serde(default)]
+ agents: Vec<ManifestCatalogAgent>,
+}
+
+#[derive(Deserialize)]
+struct ManifestCatalogAgent {
+ id: String,
+ path: String,
+}
+
+fn catalog_url() -> String {
+ std::env::var(CATALOG_URL_ENV).unwrap_or_else(|_| DEFAULT_CATALOG_URL.to_string())
+}
+
+fn fetch_text(url: &str) -> Result<String, String> {
+ let output = Command::new("curl")
+ .args(["-fsSL", "--max-time", "30", url])
+ .output()
+ .map_err(|err| format!("failed to run curl: {err}"))?;
+ if !output.status.success() {
+ return Err(format!("curl {url} failed: {}", output.status));
+ }
+ String::from_utf8(output.stdout).map_err(|err| format!("non-utf8 response: {err}"))
+}
+
+pub fn update() -> Result<(), String> {
+ let url = catalog_url();
+ let catalog_text = fetch_text(&url)?;
+ let catalog: ManifestCatalog = toml::from_str(&catalog_text)
+ .map_err(|err| format!("invalid catalog: {err}"))?;
+
+ let base = url.rsplit_once('/').map(|(prefix, _)| prefix).unwrap_or("");
+ let store = remote_store_dir();
+ std::fs::create_dir_all(&store).map_err(|err| format!("mkdir failed: {err}"))?;
+
+ let mut stored = 0usize;
+ for entry in &catalog.agents {
+ let Some(agent) = Agent::ALL.iter().copied().find(|a| agent_label(*a) == entry.id)
+ else {
+ continue;
+ };
+ let manifest_url = format!("{base}/{}", entry.path);
+ let content = match fetch_text(&manifest_url) {
+ Ok(content) => content,
+ Err(err) => {
+ tracing::warn!(agent = %entry.id, %err, "skipping manifest");
+ continue;
+ }
+ };
+ std::fs::write(remote_manifest_path(agent), content)
+ .map_err(|err| format!("write failed: {err}"))?;
+ stored += 1;
+ }
+
+ info!(stored, "manifest update complete");
+ println!(
+ "stored {stored} manifests into {}\nrestart `dingger watch` to pick them up",
+ store.display()
+ );
+ Ok(())
+}
diff --git a/crates/dinggerd/src/notify.rs b/crates/dinggerd/src/notify.rs
new file mode 100644
index 0000000..9fcfcde
--- /dev/null
+++ b/crates/dinggerd/src/notify.rs
@@ -0,0 +1,76 @@
+//! Notifications and sounds, mirroring herdr's macOS delivery.
+
+use std::process::{Command, Stdio};
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use tracing::{debug, warn};
+
+pub enum NotificationKind {
+ NeedsAttention,
+ Finished,
+}
+
+impl NotificationKind {
+ pub fn title(&self) -> &'static str {
+ match self {
+ Self::NeedsAttention => "needs attention",
+ Self::Finished => "finished",
+ }
+ }
+
+ /// herdr's bundled notification sounds; `request` for blockers, `done`
+ /// for completions.
+ fn sound_bytes(&self) -> &'static [u8] {
+ match self {
+ Self::NeedsAttention => include_bytes!("../assets/request.mp3"),
+ Self::Finished => include_bytes!("../assets/done.mp3"),
+ }
+ }
+}
+
+static SOUND_SEQ: AtomicU64 = AtomicU64::new(0);
+
+pub fn play_sound(kind: &NotificationKind) {
+ // Sounds are embedded; afplay needs a file, so stage the bytes in the
+ // system temp dir.
+ let path = std::env::temp_dir().join(format!(
+ "dingger-{}-{}.mp3",
+ std::process::id(),
+ SOUND_SEQ.fetch_add(1, Ordering::Relaxed)
+ ));
+ if let Err(err) = std::fs::write(&path, kind.sound_bytes()) {
+ warn!(%err, "failed to stage notification sound");
+ return;
+ }
+ match Command::new("afplay")
+ .arg(&path)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ {
+ // Fire and forget: never let audio latency block the watch loop.
+ Ok(mut child) => {
+ std::thread::spawn(move || {
+ let _ = child.wait();
+ let _ = std::fs::remove_file(&path);
+ });
+ }
+ Err(err) => {
+ warn!(%err, "failed to play notification sound");
+ let _ = std::fs::remove_file(&path);
+ }
+ }
+}
+
+pub fn send_notification(agent_label: &str, kind: &NotificationKind, context: &str) {
+ debug!(agent = agent_label, kind = kind.title(), context, "notifying");
+ let title = format!("{agent_label} {}", kind.title());
+ let notification = mac_notification_sys::Notification::new()
+ .title(title.as_str())
+ .message(context)
+ .send();
+ if let Err(err) = notification {
+ warn!(%err, "failed to deliver macOS notification");
+ }
+}
diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs
new file mode 100644
index 0000000..04a6c77
--- /dev/null
+++ b/crates/dinggerd/src/tmux.rs
@@ -0,0 +1,177 @@
+//! tmux IPC wrapper. Everything dingger knows comes from these commands.
+
+use std::process::Command;
+
+use detect_core::manifest::agent_state_label;
+use detect_core::{identify_agent, Agent, AgentState};
+
+#[derive(Debug, Clone)]
+pub struct Pane {
+ /// Unique tmux pane id, e.g. `%3`. Stable for the pane's lifetime.
+ pub pane_id: String,
+ /// User-facing target, e.g. `main:1.0`.
+ pub target: String,
+ /// Foreground command name in the pane, from `#{pane_current_command}`.
+ pub current_command: String,
+ /// Last OSC title the pane's application set, from `#{pane_title}`.
+ pub pane_title: String,
+ pub dead: bool,
+ /// The pane's window is the active window of its session.
+ pub window_active: bool,
+ /// The pane's session has at least one attached client.
+ pub session_attached: bool,
+ pub agent: Option<Agent>,
+ /// The `@agent_state` user option previously written for this pane,
+ /// read straight from the pane format.
+ pub written_state: String,
+}
+
+impl Pane {
+ /// Panes the user is actively looking at suppress notifications, mirroring
+ /// herdr's active-tab suppression.
+ pub fn suppresses_notifications(&self) -> bool {
+ self.window_active && self.session_attached
+ }
+}
+
+fn run_tmux(args: &[&str]) -> Result<String, String> {
+ let mut command = Command::new("tmux");
+ // -L selects a named server socket; lets tests and multi-server setups
+ // point dingger at a specific server.
+ if let Some(socket) = std::env::var_os("DINGGER_TMUX_SOCKET") {
+ command.arg("-L").arg(socket);
+ }
+ let output = command
+ .args(args)
+ .output()
+ .map_err(|err| format!("failed to run tmux: {err}"))?;
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("tmux {} failed: {}", args.join(" "), stderr.trim()));
+ }
+ Ok(String::from_utf8_lossy(&output.stdout).into_owned())
+}
+
+pub fn list_panes() -> Result<Vec<Pane>, String> {
+ let format = concat!(
+ "#{pane_id}\t",
+ "#{session_name}:#{window_index}.#{pane_index}\t",
+ "#{pane_current_command}\t",
+ "#{pane_title}\t",
+ "#{pane_dead}\t",
+ "#{window_active}\t",
+ "#{session_attached}\t",
+ "#{pane_start_command}\t",
+ "#{pane_pid}\t",
+ "#{@agent_state}"
+ );
+ let out = run_tmux(&["list-panes", "-a", "-F", format])?;
+ let mut panes = Vec::new();
+ for line in out.lines() {
+ let fields: Vec<&str> = line.split('\t').collect();
+ if fields.len() < 10 {
+ continue;
+ }
+ let current_command = fields[2].to_string();
+ let start_command = fields[7].to_string();
+ let pane_pid: Option<u32> = fields[8].trim().parse().ok();
+ // An agent may be started via a wrapper or still be initializing
+ // behind the shell; `pane_start_command` covers launch-command cases.
+ let mut agent =
+ identify_agent(&current_command).or_else(|| identify_agent(&start_command));
+ // Some agents rename their process (claude sets its title to the
+ // version string), so `pane_current_command` alone is not enough.
+ let mut process_tree = None;
+ if agent.is_none() {
+ let tree = process_tree.get_or_insert_with(fetch_process_tree);
+ agent = resolve_agent_in_process_tree(pane_pid, tree);
+ }
+ panes.push(Pane {
+ pane_id: fields[0].to_string(),
+ target: fields[1].to_string(),
+ current_command,
+ pane_title: fields[3].to_string(),
+ dead: fields[4] == "1",
+ window_active: fields[5] == "1",
+ session_attached: fields[6] != "0",
+ agent,
+ written_state: fields[9].to_string(),
+ });
+ }
+ Ok(panes)
+}
+
+type ProcessTree = (std::collections::HashMap<u32, Vec<u32>>, std::collections::HashMap<u32, String>);
+
+/// Identify the pane's agent by walking its process tree. Some agents rename
+/// their process (claude sets its title to the version string), so the kernel
+/// name in `pane_current_command` is not enough; `ps` still shows the real
+/// binary path. One `ps` call per `list_panes` covers all panes.
+fn resolve_agent_in_process_tree(pane_pid: Option<u32>, tree: &ProcessTree) -> Option<Agent> {
+ let pane_pid = pane_pid?;
+ let (children, comm) = tree;
+
+ // BFS from the pane's shell.
+ let mut queue = std::collections::VecDeque::from([pane_pid]);
+ while let Some(pid) = queue.pop_front() {
+ if let Some(name) = comm.get(&pid) {
+ if let Some(agent) = identify_agent(name) {
+ return Some(agent);
+ }
+ }
+ if let Some(child_ids) = children.get(&pid) {
+ for child in child_ids {
+ queue.push_back(*child);
+ }
+ }
+ }
+ None
+}
+
+fn fetch_process_tree() -> ProcessTree {
+ let Ok(output) = Command::new("ps").args(["-axo", "pid=,ppid=,comm="]).output() else {
+ return Default::default();
+ };
+ if !output.status.success() {
+ return Default::default();
+ }
+ let mut children: std::collections::HashMap<u32, Vec<u32>> =
+ std::collections::HashMap::new();
+ let mut comm: std::collections::HashMap<u32, String> = std::collections::HashMap::new();
+ for line in String::from_utf8_lossy(&output.stdout).lines() {
+ // Columns are whitespace-aligned, so runs of spaces separate pid,
+ // ppid, and the command path; the command itself may contain spaces.
+ let Some((pid, rest)) = line.trim_start().split_once(char::is_whitespace) else {
+ continue;
+ };
+ let Some((ppid, name)) = rest.trim_start().split_once(char::is_whitespace) else {
+ continue;
+ };
+ let name = name.trim_start();
+ let (Ok(pid), Ok(ppid)) = (pid.parse::<u32>(), ppid.parse::<u32>()) else {
+ continue;
+ };
+ children.entry(ppid).or_default().push(pid);
+ let basename = name.rsplit('/').next().unwrap_or(name);
+ comm.insert(pid, basename.to_string());
+ }
+ (children, comm)
+}
+
+/// Capture the pane's visible screen as plain text — the exact input shape
+/// the detection manifests expect.
+pub fn capture_pane(pane_id: &str) -> Result<String, String> {
+ run_tmux(&["capture-pane", "-p", "-q", "-t", pane_id])
+}
+
+pub fn set_agent_state(pane_id: &str, state: AgentState) -> Result<(), String> {
+ run_tmux(&[
+ "set-option",
+ "-p",
+ "-t",
+ pane_id,
+ "@agent_state",
+ agent_state_label(state),
+ ])
+ .map(|_| ())
+}
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
new file mode 100644
index 0000000..46f5ebd
--- /dev/null
+++ b/crates/dinggerd/src/watcher.rs
@@ -0,0 +1,156 @@
+//! Poll-and-detect loop: tmux pane capture → detection engine → herdr's
+//! transition policy → notifications and `@agent_state` write-back.
+
+use std::collections::HashMap;
+
+use detect_core::{detect_agent_with_osc, AgentState};
+use tracing::{debug, info, warn};
+
+use crate::notify::{play_sound, send_notification, NotificationKind};
+use crate::tmux;
+
+pub struct Watcher {
+ /// Last published state per pane id. `status` reads the same values back
+ /// from tmux's `@agent_state`, so no other state is needed.
+ states: HashMap<String, AgentState>,
+ interval_secs: u64,
+ sounds: bool,
+}
+
+/// herdr's notification policy (`notification_toast_for_state_change`).
+pub fn notification_kind_for_transition(
+ prev_state: AgentState,
+ new_state: AgentState,
+) -> Option<NotificationKind> {
+ if new_state == prev_state {
+ return None;
+ }
+ match new_state {
+ AgentState::Blocked => Some(NotificationKind::NeedsAttention),
+ AgentState::Idle
+ if matches!(prev_state, AgentState::Working | AgentState::Blocked) =>
+ {
+ Some(NotificationKind::Finished)
+ }
+ _ => None,
+ }
+}
+
+impl Watcher {
+ pub fn new(interval_secs: u64, sounds: bool) -> Self {
+ Self {
+ states: HashMap::new(),
+ interval_secs: interval_secs.max(1),
+ sounds,
+ }
+ }
+
+ pub fn run(&mut self) -> Result<(), String> {
+ // Fail fast when tmux is unreachable at startup; after that, keep
+ // retrying — the server may come back.
+ self.tick()?;
+ info!(interval = self.interval_secs, "watching tmux panes");
+ loop {
+ if let Err(err) = self.tick() {
+ warn!(%err, "watch tick failed");
+ }
+ std::thread::sleep(std::time::Duration::from_secs(self.interval_secs));
+ }
+ }
+
+ fn tick(&mut self) -> Result<(), String> {
+ let panes = tmux::list_panes()?;
+
+ // Forget panes that went away.
+ self.states
+ .retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id));
+
+ for pane in panes {
+ let prev_state = self
+ .states
+ .get(&pane.pane_id)
+ .copied()
+ .unwrap_or(AgentState::Unknown);
+
+ // The detection result is an observation of live agent state.
+ // Viewer screens (transcripts, pickers) return
+ // `skip_state_update: true` — they say nothing about state and
+ // leave the previous one standing (herdr's publishing contract).
+ let observed = if pane.dead {
+ Some(AgentState::Idle)
+ } else if let Some(agent) = pane.agent {
+ let screen = match tmux::capture_pane(&pane.pane_id) {
+ Ok(screen) => screen,
+ Err(err) => {
+ warn!(target = %pane.target, %err, "capture failed");
+ continue;
+ }
+ };
+ // tmux pane_title carries the last OSC title the application
+ // set; tmux does not re-expose OSC progress sequences.
+ let detection =
+ detect_agent_with_osc(Some(agent), &screen, &pane.pane_title, "");
+ (!detection.skip_state_update).then_some(detection.state)
+ } else {
+ // A pane that stops running an agent (agent quit into a
+ // shell) is no longer an observation.
+ Some(AgentState::Unknown)
+ };
+
+ let Some(new_state) = observed else {
+ debug!(target = %pane.target, "observation skipped (viewer screen)");
+ continue;
+ };
+ if new_state == prev_state {
+ continue;
+ }
+ self.states.insert(pane.pane_id.clone(), new_state);
+ debug!(target = %pane.target, ?prev_state, ?new_state, "state change");
+
+ if let Err(err) = tmux::set_agent_state(&pane.pane_id, new_state) {
+ warn!(target = %pane.target, %err, "failed to write @agent_state");
+ }
+
+ let agent_label = pane
+ .agent
+ .map(detect_core::agent_label)
+ .unwrap_or("agent")
+ .to_string();
+ if let Some(kind) = notification_kind_for_transition(prev_state, new_state) {
+ if pane.suppresses_notifications() {
+ debug!(target = %pane.target, "suppressed (active pane)");
+ continue;
+ }
+ let context = format!("{} ({})", pane.target, agent_label);
+ send_notification(&agent_label, &kind, &context);
+ if self.sounds {
+ play_sound(&kind);
+ }
+ }
+ }
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn policy_matches_herdr() {
+ assert!(notification_kind_for_transition(AgentState::Working, AgentState::Blocked)
+ .is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention)));
+ assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Blocked).is_none());
+ assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Blocked)
+ .is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention)));
+ assert!(notification_kind_for_transition(AgentState::Working, AgentState::Idle)
+ .is_some_and(|kind| matches!(kind, NotificationKind::Finished)));
+ assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Idle)
+ .is_some_and(|kind| matches!(kind, NotificationKind::Finished)));
+ // Unknown → Idle is a first observation, not a completion.
+ assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Idle).is_none());
+ assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Working).is_none());
+ assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Unknown).is_none());
+ }
+}