diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -439,6 +439,7 @@ dependencies = [
  "serde_json",
  "thiserror 2.0.21",
  "toml 1.1.7+spec-1.1.0",
+ "toml_edit 0.25.16+spec-1.1.0",
 ]
 
 [[package]]
@@ -3883,6 +3884,7 @@ dependencies = [
  "indexmap 2.14.2",
  "toml_datetime 1.1.2+spec-1.1.0",
  "toml_parser",
+ "toml_writer",
  "winnow 1.0.4",
 ]
 
diff --git a/Cargo.toml b/Cargo.toml
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,6 +11,7 @@ license = "MIT"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 toml = "1"
+toml_edit = "0.25"
 dirs = "7"
 thiserror = "2"
 chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -97,7 +97,7 @@ scored.
   repo it pauses after each GraphQL request as long as it took: GitHub allows
   only 60 s of GraphQL server time per minute, and its secondary rate limits
   punish bursts. Each repo costs about six GraphQL requests and one REST
-  request. It never uses search, which GitHub limits far more tightly (to
+  request. Fetches never use search, which GitHub limits far more tightly (to
   about 30 a minute); PR and issue counts per window come from walking the
   recent PRs and issues.
 - Each repo's icon is `assets/app-icon/hicolor/64x64/apps/*.png` on its default
@@ -125,6 +125,17 @@ scored.
   config, then from `gh auth token`. The app is started by launchd, not a
   shell, so it sees variables set with `launchctl setenv` (then relaunch it),
   not those exported in your shell profile.
+- The app also adds a row for each repo where you (the token's user) have an
+  open pull request, when `repos` lists another repo of the same owner. Every
+  `poll_minutes` it searches each such owner once, and it sends each search
+  like a repo fetch: 20 s after the request before it, after any row
+  refreshed by hand, never during a pause, and not while fewer than 1,500
+  points remain. An added row is marked `added`. The app removes it once two
+  rounds of searches in a row find none of your open pull requests there;
+  its cached activity stays on disk. **Keep** writes the repo into `repos` in
+  the config file. **Hide** writes it into `hidden_auto_repos`, and the app
+  does not add it again. The app writes the config file only for these two
+  buttons. `auto_repos = false` turns this off.
 
 ## Tray and window
 
diff --git a/craft_status_config.template.toml b/craft_status_config.template.toml
--- a/craft_status_config.template.toml
+++ b/craft_status_config.template.toml
@@ -34,6 +34,15 @@ stale_minutes = 60.0
 # this file lives in your home directory, not in the repository.
 github_token = ""
 
+# Add a row for each repo where the token's user has an open pull request,
+# when `repos` lists another repo of the same owner. Every `poll_minutes` the
+# app searches each such owner once, 20 s after the request before it. An
+# added row has Keep, which writes it into `repos`, and Hide, which writes it
+# into `hidden_auto_repos` so that it is not added again. The app does not
+# edit this file except for these two buttons.
+auto_repos = true
+hidden_auto_repos = []
+
 [window]
 width = 1340.0
 height = 560.0
diff --git a/crates/craft-core/Cargo.toml b/crates/craft-core/Cargo.toml
--- a/crates/craft-core/Cargo.toml
+++ b/crates/craft-core/Cargo.toml
@@ -9,6 +9,7 @@ license.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 toml.workspace = true
+toml_edit.workspace = true
 dirs.workspace = true
 thiserror.workspace = true
 chrono.workspace = true
diff --git a/crates/craft-core/src/config.rs b/crates/craft-core/src/config.rs
--- a/crates/craft-core/src/config.rs
+++ b/crates/craft-core/src/config.rs
@@ -39,6 +39,11 @@ pub struct Config {
     /// Optional, used only when `CRAFT_STATUS_GITHUB_TOKEN`, `GITHUB_TOKEN`
     /// and `GH_TOKEN` are unset; otherwise `gh auth token`.
     pub github_token: String,
+    /// Add a row for each repo where the viewer has an open pull request,
+    /// when `repos` lists another repo of the same owner ([`Config::may_add`]).
+    pub auto_repos: bool,
+    /// Repos never added that way: `owner/name` or full GitHub URLs.
+    pub hidden_auto_repos: Vec<String>,
     pub window: WindowConfig,
     pub tray: TrayConfig,
     pub shortcuts: Shortcuts,
@@ -75,6 +80,8 @@ impl Default for Config {
             poll_minutes: 30.0,
             stale_minutes: 60.0,
             github_token: String::new(),
+            auto_repos: true,
+            hidden_auto_repos: Vec::new(),
             window: WindowConfig::default(),
             tray: TrayConfig::default(),
             shortcuts: Shortcuts::default(),
@@ -179,6 +186,203 @@ impl Config {
     }
 }
 
+/// A repo list in the config file that a row added by the poller writes to.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RepoList {
+    /// `repos`: the row's Keep.
+    Watched,
+    /// `hidden_auto_repos`: the row's Hide.
+    Hidden,
+}
+
+impl RepoList {
+    fn key(self) -> &'static str {
+        match self {
+            RepoList::Watched => "repos",
+            RepoList::Hidden => "hidden_auto_repos",
+        }
+    }
+
+    fn of(self, config: &Config) -> &[String] {
+        match self {
+            RepoList::Watched => &config.repos,
+            RepoList::Hidden => &config.hidden_auto_repos,
+        }
+    }
+}
+
+impl Config {
+    /// The owners of the configured repos, each once, in config order.
+    pub fn owners(&self) -> Vec<String> {
+        let mut out: Vec<String> = Vec::new();
+        for (owner, _) in self.repos.iter().filter_map(|r| r.split_once('/')) {
+            if !out.iter().any(|o| o.eq_ignore_ascii_case(owner)) {
+                out.push(owner.to_string());
+            }
+        }
+        out
+    }
+
+    /// Whether the poller may add `repo` to the table on its own:
+    /// `auto_repos` is on, `repos` lists another repo of the same owner, and
+    /// neither `repos` nor `hidden_auto_repos` lists `repo`.
+    pub fn may_add(&self, repo: &str) -> bool {
+        let Some(repo) = normalize_repo(repo) else {
+            return false;
+        };
+        let owner = repo.split('/').next().unwrap_or_default();
+        self.auto_repos
+            && self.owners().iter().any(|o| o.eq_ignore_ascii_case(owner))
+            && !lists(&self.repos, &repo)
+            && !lists(&self.hidden_auto_repos, &repo)
+    }
+
+    /// Adds `repo` to `list` in the config file at `path` (written from the
+    /// template first when there is none) with [`add_to_list`], through a
+    /// temporary file and a rename. A symlink is followed, and the file keeps
+    /// its permissions: the temporary file gets them before any text goes in.
+    pub fn add_to_file(path: &Path, list: RepoList, repo: &str) -> Result<()> {
+        let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
+        let io = |source| Error::Io {
+            path: path.display().to_string(),
+            source,
+        };
+        let text = match std::fs::read_to_string(&path) {
+            Ok(text) => text,
+            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+                return atomic_write(&path, &add_to_list(TEMPLATE, list, repo)?);
+            }
+            Err(e) => return Err(io(e)),
+        };
+        let out = add_to_list(&text, list, repo)?;
+        let mode = std::fs::metadata(&path).map_err(io)?.permissions();
+        let tmp = path.with_extension("tmp");
+        let _ = std::fs::remove_file(&tmp);
+        let mut file = std::fs::OpenOptions::new()
+            .write(true)
+            .create_new(true)
+            .open(&tmp)
+            .map_err(io)?;
+        file.set_permissions(mode).map_err(io)?;
+        std::io::Write::write_all(&mut file, out.as_bytes()).map_err(io)?;
+        drop(file);
+        std::fs::rename(&tmp, &path).map_err(io)
+    }
+}
+
+/// Whether `list` holds `repo` (`owner/name`), in any spelling.
+fn lists(list: &[String], repo: &str) -> bool {
+    list.iter()
+        .filter_map(|r| normalize_repo(r))
+        .any(|r| r.eq_ignore_ascii_case(repo))
+}
+
+/// `text`, a config file, with `repo` added to `list`. Comments and
+/// formatting stay; on a list written one repo per line, the new repo gets a
+/// line of its own and a trailing comma, and a comment on the last repo's
+/// line stays on that line. A list the file lacks is written with the repos
+/// it reads as (for `repos`, the defaults), then `repo`, above the first
+/// table. A repo the list already holds is not added again.
+pub fn add_to_list(text: &str, list: RepoList, repo: &str) -> Result<String> {
+    use toml_edit::{Array, DocumentMut, Item, RawString, TomlError, Value};
+    let invalid = |what: &str, e: String| Error::Io {
+        path: what.to_string(),
+        source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
+    };
+    let raw = |s: Option<&RawString>| {
+        s.and_then(RawString::as_str)
+            .unwrap_or_default()
+            .to_string()
+    };
+    let held = Config::parse(text)?;
+    let repo = normalize_repo(repo)
+        .ok_or_else(|| invalid(repo, "not owner/name or a GitHub URL".into()))?;
+    let held = list.of(&held);
+    if lists(held, &repo) {
+        return Ok(text.to_string());
+    }
+    let wanted = repo.clone();
+    // `Config::parse` has read the same text, so this is not expected to fail.
+    let mut doc: DocumentMut = text
+        .parse()
+        .map_err(|e: TomlError| invalid("config", e.to_string()))?;
+    match doc.get_mut(list.key()).and_then(Item::as_array_mut) {
+        Some(array) if !array.is_empty() => {
+            // On a list without a trailing comma, the line break after the
+            // last repo moves after a new comma.
+            let last = array.iter().last();
+            let suffix = raw(last.and_then(|v| v.decor().suffix()));
+            if suffix.contains('\n') {
+                if let Some(last) = array.iter_mut().last() {
+                    last.decor_mut().set_suffix("");
+                }
+                let trailing = raw(Some(array.trailing()));
+                array.set_trailing(format!("{suffix}{trailing}"));
+                array.set_trailing_comma(true);
+            }
+            // The indent of the last repo, without any comment above it.
+            let indent = array
+                .iter()
+                .last()
+                .and_then(|v| v.decor().prefix())
+                .and_then(|p| p.as_str())
+                .and_then(|p| p.rfind('\n').map(|i| p[i..].to_string()))
+                .unwrap_or_else(|| " ".to_string());
+            // A comment on the last repo's line stays on that line.
+            let trailing = raw(Some(array.trailing()));
+            let (line, rest) = trailing.split_at(trailing.find('\n').unwrap_or(trailing.len()));
+            let mut value = Value::from(repo);
+            if line.contains('#') {
+                let indent = if indent.starts_with('\n') {
+                    indent
+                } else {
+                    "\n  ".to_string()
+                };
+                value.decor_mut().set_prefix(format!("{line}{indent}"));
+                array.set_trailing(rest);
+            } else {
+                value.decor_mut().set_prefix(indent);
+            }
+            array.push_formatted(value);
+        }
+        Some(array) => array.push(repo),
+        None => {
+            let mut array = Array::new();
+            for r in held.iter().cloned().chain([repo]) {
+                let mut value = Value::from(r);
+                value.decor_mut().set_prefix("\n  ");
+                array.push_formatted(value);
+            }
+            array.set_trailing_comma(true);
+            array.set_trailing("\n");
+            doc.insert(list.key(), Item::Value(Value::Array(array)));
+            // The new list goes above the file's first table, with a blank
+            // line between them.
+            let first_table = doc
+                .as_table_mut()
+                .iter_mut()
+                .filter_map(|(_, item)| item.as_table_mut())
+                .filter(|t| !t.is_implicit())
+                .min_by_key(|t| t.position());
+            if let Some(table) = first_table {
+                let prefix = raw(table.decor().prefix());
+                if !prefix.starts_with('\n') {
+                    table.decor_mut().set_prefix(format!("\n{prefix}"));
+                }
+            }
+        }
+    }
+    let out = doc.to_string();
+    // Fail when the new text does not list the repo.
+    if !lists(list.of(&Config::parse(&out)?), &wanted) {
+        return Err(invalid(
+            "config",
+            format!("could not add {wanted} to {}", list.key()),
+        ));
+    }
+    Ok(out)
+}
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
 pub struct WindowState {
     pub x: i32,
@@ -272,4 +476,173 @@ mod tests {
         assert!(w.visible_on(&[(0, 0, 1920, 1080)]));
         assert!(!w.visible_on(&[(2000, 0, 1920, 1080)]));
     }
+
+    #[test]
+    fn repos_are_added_only_under_a_configured_owner() {
+        let c = Config::parse(
+            r#"repos = ["https://github.com/Acme/one", "acme/two", "other-org/lib"]
+               hidden_auto_repos = ["https://github.com/acme/hidden"]"#,
+        )
+        .unwrap();
+        assert_eq!(c.owners(), vec!["Acme", "other-org"]);
+        assert!(c.auto_repos, "on by default");
+        assert!(c.may_add("acme/three"));
+        assert!(c.may_add("OTHER-ORG/tool"));
+        assert!(!c.may_add("stranger/three"), "an owner the config lacks");
+        assert!(!c.may_add("ACME/Two"), "already configured");
+        assert!(!c.may_add("acme/hidden"));
+        assert!(!c.may_add("not a repo"));
+        let off = Config::parse("auto_repos = false\nrepos = [\"acme/one\"]").unwrap();
+        assert!(!off.may_add("acme/three"));
+    }
+
+    #[test]
+    fn keep_and_hide_add_to_the_lists_and_keep_the_comments() {
+        let kept = add_to_list(
+            TEMPLATE,
+            RepoList::Watched,
+            "https://github.com/storytold/newcraft",
+        )
+        .unwrap();
+        assert!(kept.contains("# Repositories to watch"));
+        assert!(kept.contains("  \"storytold/figcraft\",\n  \"storytold/newcraft\",\n]"));
+        let c = Config::parse(&kept).unwrap();
+        assert_eq!(
+            c.repos.last().map(String::as_str),
+            Some("storytold/newcraft")
+        );
+        assert_eq!(c.repos.len(), DEFAULT_REPOS.len() + 1);
+        assert_eq!(
+            add_to_list(&kept, RepoList::Watched, "Storytold/NewCraft").unwrap(),
+            kept,
+            "already listed"
+        );
+
+        let hidden = add_to_list(&kept, RepoList::Hidden, "storytold/scratch").unwrap();
+        assert!(hidden.contains("hidden_auto_repos = [\"storytold/scratch\"]"));
+        let c = Config::parse(&hidden).unwrap();
+        assert_eq!(c.hidden_auto_repos, vec!["storytold/scratch"]);
+        assert!(!c.may_add("storytold/scratch"));
+
+        // A comment above the last repo stays where it is, once.
+        let commented = "repos = [\n  \"a/one\",\n  # libraries\n  \"a/lib\",\n]\n";
+        assert_eq!(
+            add_to_list(commented, RepoList::Watched, "a/two").unwrap(),
+            "repos = [\n  \"a/one\",\n  # libraries\n  \"a/lib\",\n  \"a/two\",\n]\n"
+        );
+        assert_eq!(
+            add_to_list("repos = [\"a/one\"]\n", RepoList::Watched, "a/two").unwrap(),
+            "repos = [\"a/one\", \"a/two\"]\n"
+        );
+    }
+
+    #[test]
+    fn a_list_without_a_trailing_comma_or_with_a_comment_on_its_last_line_keeps_its_shape() {
+        let add = |text: &str| add_to_list(text, RepoList::Watched, "a/two").unwrap();
+        assert_eq!(
+            add("repos = [\n  \"a/one\",\n  \"a/lib\"\n]\n"),
+            "repos = [\n  \"a/one\",\n  \"a/lib\",\n  \"a/two\",\n]\n"
+        );
+        assert_eq!(
+            add("repos = [\n  \"a/one\",\n  \"a/lib\", # libraries\n]\n"),
+            "repos = [\n  \"a/one\",\n  \"a/lib\", # libraries\n  \"a/two\",\n]\n",
+            "the comment stays on the line of the repo it follows"
+        );
+        assert_eq!(
+            add("repos = [\n  \"a/one\",\n  \"a/lib\" # libraries\n]\n"),
+            "repos = [\n  \"a/one\",\n  \"a/lib\", # libraries\n  \"a/two\",\n]\n"
+        );
+        assert_eq!(
+            add("repos = [\n  \"a/one\",\n  \"a/lib\", # libraries\n  # the end\n]\n"),
+            "repos = [\n  \"a/one\",\n  \"a/lib\", # libraries\n  \"a/two\",\n  # the end\n]\n"
+        );
+        // The last repo on the bracket's line, with a comment after it.
+        for text in [
+            "repos = [\"a/one\", # one\n]\n",
+            "repos = [\"a/one\" # one\n]\n",
+        ] {
+            let out = add(text);
+            assert_eq!(out, "repos = [\"a/one\", # one\n  \"a/two\",\n]\n");
+            assert_eq!(Config::parse(&out).unwrap().repos, ["a/one", "a/two"]);
+        }
+    }
+
+    #[test]
+    fn a_list_the_file_lacks_is_followed_by_a_blank_line_before_a_table() {
+        let out = add_to_list("[window]\nwidth = 900.0\n", RepoList::Hidden, "a/two").unwrap();
+        assert_eq!(
+            out,
+            "hidden_auto_repos = [\n  \"a/two\",\n]\n\n[window]\nwidth = 900.0\n"
+        );
+        let spaced = "poll_minutes = 5.0\n\n[window]\nwidth = 900.0\n";
+        let out = add_to_list(spaced, RepoList::Hidden, "a/two").unwrap();
+        assert_eq!(
+            out,
+            "poll_minutes = 5.0\nhidden_auto_repos = [\n  \"a/two\",\n]\n\n[window]\nwidth = 900.0\n",
+            "one blank line, not two"
+        );
+    }
+
+    #[test]
+    fn a_list_the_file_lacks_starts_from_the_repos_it_reads_as() {
+        let text = "# mine\npoll_minutes = 5.0\n\n[window]\nwidth = 900.0\n";
+        let out = add_to_list(text, RepoList::Watched, "storytold/newcraft").unwrap();
+        assert!(out.starts_with("# mine\npoll_minutes = 5.0\n"));
+        let c = Config::parse(&out).unwrap();
+        let mut want: Vec<String> = DEFAULT_REPOS.iter().map(|s| s.to_string()).collect();
+        want.push("storytold/newcraft".into());
+        assert_eq!(c.repos, want, "the defaults stay");
+        assert_eq!((c.poll_minutes, c.window.width), (5.0, 900.0));
+        let out = add_to_list(text, RepoList::Hidden, "storytold/scratch").unwrap();
+        assert_eq!(
+            Config::parse(&out).unwrap().hidden_auto_repos,
+            vec!["storytold/scratch"]
+        );
+        assert!(add_to_list("repos = [", RepoList::Watched, "a/b").is_err());
+        assert!(add_to_list("", RepoList::Watched, "not a repo").is_err());
+    }
+
+    #[test]
+    fn keep_and_hide_survive_a_reload_and_keep_the_files_permissions() {
+        let dir = std::env::temp_dir().join(format!("craft-config-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let path = dir.join("config.toml");
+        std::fs::write(&path, "# keep me\nrepos = [\"acme/one\"]\n").unwrap();
+        #[cfg(unix)]
+        {
+            use std::os::unix::fs::PermissionsExt;
+            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
+        }
+        Config::add_to_file(&path, RepoList::Watched, "acme/two").unwrap();
+        Config::add_to_file(&path, RepoList::Hidden, "acme/three").unwrap();
+        let c = Config::load_from(&path).unwrap();
+        assert_eq!(c.repos, vec!["acme/one", "acme/two"]);
+        assert_eq!(c.hidden_auto_repos, vec!["acme/three"]);
+        assert!(std::fs::read_to_string(&path)
+            .unwrap()
+            .starts_with("# keep me\n"));
+        assert!(!path.with_extension("tmp").exists());
+        #[cfg(unix)]
+        {
+            use std::os::unix::fs::PermissionsExt;
+            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
+            assert_eq!(mode & 0o777, 0o600);
+            // Through a symlink, the file it points at is written.
+            let link = dir.join("link.toml");
+            std::os::unix::fs::symlink(&path, &link).unwrap();
+            Config::add_to_file(&link, RepoList::Watched, "acme/four").unwrap();
+            assert!(std::fs::symlink_metadata(&link).unwrap().is_symlink());
+            assert_eq!(Config::load_from(&path).unwrap().repos.len(), 3);
+        }
+        let fresh = dir.join("missing.toml");
+        Config::add_to_file(&fresh, RepoList::Hidden, "storytold/scratch").unwrap();
+        let c = Config::load_from(&fresh).unwrap();
+        assert_eq!(
+            c.repos.len(),
+            DEFAULT_REPOS.len(),
+            "written from the template"
+        );
+        assert_eq!(c.hidden_auto_repos, vec!["storytold/scratch"]);
+        std::fs::remove_dir_all(&dir).ok();
+    }
 }
diff --git a/crates/craft-core/src/model.rs b/crates/craft-core/src/model.rs
--- a/crates/craft-core/src/model.rs
+++ b/crates/craft-core/src/model.rs
@@ -113,6 +113,9 @@ pub struct RepoEntry {
     pub last_attempt_at: Option<Time>,
     /// Error from the most recent attempt, cleared on success.
     pub error: Option<String>,
+    /// The poller added this row on its own: the config doesn't list the
+    /// repo, but the token's user has an open pull request in it.
+    pub auto: bool,
     /// GitHub reported the repo missing (e.g. private and not visible to the
     /// token); shown as an empty row rather than an error.
     pub inaccessible: bool,
@@ -281,6 +284,8 @@ impl Snapshot {
     /// Make `repos` match the configured list, keeping data for repos that remain.
     pub fn sync_repos(&mut self, wanted: &[String]) {
         let mut old = std::mem::take(&mut self.repos);
+        let added = old.iter().filter(|e| e.auto).map(|e| e.repo.clone());
+        let added: Vec<String> = added.collect();
         self.repos = wanted
             .iter()
             .map(|w| {
@@ -290,6 +295,41 @@ impl Snapshot {
                     .unwrap_or_else(|| RepoEntry::new(w))
             })
             .collect();
+        // Rows the poller added stay, after the configured ones, until
+        // `set_added` drops them; a repo the config now lists is no longer one.
+        for e in &mut self.repos {
+            e.auto = false;
+        }
+        for repo in added {
+            if let Some(i) = old.iter().position(|e| e.repo == repo) {
+                self.repos.push(old.swap_remove(i));
+            }
+        }
+    }
+
+    /// Make the rows the poller added ([`RepoEntry::auto`]) match `added`:
+    /// rows it no longer lists go, keeping the others' data, and new repos
+    /// are appended in its order. A repo already in the table isn't added.
+    pub fn set_added(&mut self, added: &[String]) {
+        self.repos
+            .retain(|e| !e.auto || added.iter().any(|a| a.eq_ignore_ascii_case(&e.repo)));
+        for repo in added {
+            if !self.repos.iter().any(|e| e.repo.eq_ignore_ascii_case(repo)) {
+                self.repos.push(RepoEntry {
+                    auto: true,
+                    ..RepoEntry::new(repo)
+                });
+            }
+        }
+    }
+
+    /// The repos of the rows the poller added, in table order.
+    pub fn added(&self) -> Vec<String> {
+        self.repos
+            .iter()
+            .filter(|e| e.auto)
+            .map(|e| e.repo.clone())
+            .collect()
     }
 }
 
@@ -421,6 +461,47 @@ mod tests {
         assert_eq!(s.take_requested(), None);
     }
 
+    #[test]
+    fn added_rows_follow_the_configured_ones_and_keep_their_data() {
+        let mut s = Snapshot::default();
+        s.sync_repos(&["o/a".into(), "o/b".into()]);
+        s.set_added(&["o/x".into(), "O/A".into(), "o/y".into()]);
+        let rows = |s: &Snapshot| {
+            s.repos
+                .iter()
+                .map(|e| format!("{}{}", e.repo, if e.auto { "+" } else { "" }))
+                .collect::<Vec<_>>()
+        };
+        assert_eq!(
+            rows(&s),
+            ["o/a", "o/b", "o/x+", "o/y+"],
+            "o/a is configured"
+        );
+        assert_eq!(s.added(), ["o/x", "o/y"]);
+        s.repos[2].fetched_at = Some(Utc::now());
+        assert!(s.request("o/y"), "an added row refreshes like any other");
+
+        s.sync_repos(&["o/b".into()]);
+        assert_eq!(
+            rows(&s),
+            ["o/b", "o/x+", "o/y+"],
+            "a config reload keeps them"
+        );
+        assert!(s.repos[1].fetched_at.is_some());
+
+        s.sync_repos(&["o/b".into(), "o/x".into()]);
+        assert_eq!(rows(&s), ["o/b", "o/x", "o/y+"], "kept: now configured");
+        assert!(s.repos[1].fetched_at.is_some(), "with its data");
+
+        s.set_added(&[]);
+        assert_eq!(rows(&s), ["o/b", "o/x"]);
+        assert_eq!(s.take_requested(), None, "its refresh went with it");
+
+        let back: Snapshot =
+            serde_json::from_str(r#"{"repos": [{"repo": "o/z", "auto": true}]}"#).unwrap();
+        assert!(back.repos[0].auto, "restored from the cache");
+    }
+
     #[test]
     fn hand_refreshes_are_not_restored_from_the_cache() {
         let mut s = Snapshot::default();
diff --git a/crates/craft-github/src/lib.rs b/crates/craft-github/src/lib.rs
--- a/crates/craft-github/src/lib.rs
+++ b/crates/craft-github/src/lib.rs
@@ -431,6 +431,69 @@ impl GitHub {
         }
         Ok(out)
     }
+
+    /// The login of the user the token belongs to, from one
+    /// `viewer { login }` query.
+    pub fn token_login(&self) -> Result<String> {
+        let v = self.graphql("query { viewer { login } }", json!({}))?;
+        v["viewer"]["login"]
+            .as_str()
+            .map(str::to_string)
+            .ok_or_else(|| Error::GraphQl("no login for the token's user".into()))
+    }
+
+    /// The repos owned by `owner` where `login` has an open pull request,
+    /// from one search ([`OPEN_PRS_QUERY`]) read to its last page, and the
+    /// GraphQL points left after it. This is the app's only search; the
+    /// poller sends it once per owner every `poll_minutes`.
+    pub fn open_pr_repos(&self, login: &str, owner: &str) -> Result<OpenPrs> {
+        let q = format!("is:pr is:open author:{login} user:{owner}");
+        walk_open_prs(|after| self.graphql(OPEN_PRS_QUERY, json!({ "q": q, "after": after })))
+    }
+}
+
+/// What [`GitHub::open_pr_repos`] found.
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct OpenPrs {
+    /// `owner/name` of each repo with one of the pull requests.
+    pub repos: std::collections::BTreeSet<String>,
+    /// GraphQL points left after the last page.
+    pub graphql_remaining: Option<u64>,
+    /// The search was read to its last page. False when it held more than
+    /// [`SEARCH_PAGES`] pages, so a repo missing from `repos` may still have
+    /// an open pull request.
+    pub complete: bool,
+}
+
+/// Reads [`OPEN_PRS_QUERY`]'s pages, each from `page` given the cursor to
+/// start after (`None` first), until the last page or [`SEARCH_PAGES`].
+pub fn walk_open_prs(mut page: impl FnMut(Option<String>) -> Result<Value>) -> Result<OpenPrs> {
+    let mut out = OpenPrs::default();
+    let mut after = None;
+    for _ in 0..SEARCH_PAGES {
+        let v = page(after.take())?;
+        let conn = &v["search"];
+        out.repos.extend(
+            conn["nodes"]
+                .as_array()
+                .into_iter()
+                .flatten()
+                .filter_map(|n| n["repository"]["nameWithOwner"].as_str())
+                .map(str::to_string),
+        );
+        out.graphql_remaining = v["rateLimit"]["remaining"]
+            .as_u64()
+            .or(out.graphql_remaining);
+        if conn["pageInfo"]["hasNextPage"].as_bool() != Some(true) {
+            out.complete = true;
+            break;
+        }
+        after = conn["pageInfo"]["endCursor"].as_str().map(str::to_string);
+        if after.is_none() {
+            break;
+        }
+    }
+    Ok(out)
 }
 
 pub fn parse_contributors(body: &Value) -> Vec<String> {
@@ -947,6 +1010,20 @@ query($owner: String!, $name: String!, $tag: String!, $branch: String!, $at: Git
 }
 "#;
 
+/// Pages of 100 a search may take: GitHub returns at most 1,000 results.
+const SEARCH_PAGES: usize = 10;
+
+/// One page of the pull requests a search finds, by repo.
+const OPEN_PRS_QUERY: &str = r#"
+query($q: String!, $after: String) {
+  search(type: ISSUE, query: $q, first: 100, after: $after) {
+    pageInfo { hasNextPage endCursor }
+    nodes { ... on PullRequest { repository { nameWithOwner } } }
+  }
+  rateLimit { remaining }
+}
+"#;
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1218,6 +1295,51 @@ mod tests {
         std::fs::remove_dir_all(&dir).ok();
     }
 
+    #[test]
+    fn the_open_pull_request_search_is_read_to_its_last_page() {
+        let pages = [
+            json!({"search": {"pageInfo": {"hasNextPage": true, "endCursor": "c1"}, "nodes": [
+                {"repository": {"nameWithOwner": "acme/one"}},
+                {"repository": {"nameWithOwner": "acme/two"}},
+                {"repository": {"nameWithOwner": "acme/one"}}
+            ]}, "rateLimit": {"remaining": 4990}}),
+            json!({"search": {"pageInfo": {"hasNextPage": false, "endCursor": "c2"}, "nodes": [
+                {"repository": {"nameWithOwner": "acme/three"}}, {}
+            ]}, "rateLimit": {"remaining": 4989}}),
+        ];
+        let mut cursors = Vec::new();
+        let found = walk_open_prs(|after| {
+            let v = pages[cursors.len()].clone();
+            cursors.push(after);
+            Ok(v)
+        })
+        .unwrap();
+        assert_eq!(cursors, [None, Some("c1".to_string())]);
+        assert_eq!(
+            found.repos.into_iter().collect::<Vec<_>>(),
+            ["acme/one", "acme/three", "acme/two"]
+        );
+        assert_eq!(found.graphql_remaining, Some(4989));
+        assert!(found.complete);
+
+        let mut calls = 0;
+        let failed = walk_open_prs(|_| {
+            calls += 1;
+            Err(Error::RateLimited("later".into()))
+        });
+        assert!(matches!(failed, Err(Error::RateLimited(_))));
+        assert_eq!(calls, 1);
+
+        let mut calls = 0;
+        let endless = walk_open_prs(|_| {
+            calls += 1;
+            Ok(json!({"search": {"pageInfo": {"hasNextPage": true, "endCursor": "again"}, "nodes": []}}))
+        })
+        .unwrap();
+        assert_eq!((calls, endless.graphql_remaining), (SEARCH_PAGES, None));
+        assert!(!endless.complete, "more pages than a search may take");
+    }
+
     #[test]
     fn empty_repository_is_all_zero() {
         let mut s = build_stats(
diff --git a/crates/craft-status-app/src/added.rs b/crates/craft-status-app/src/added.rs
new file mode 100644
--- /dev/null
+++ b/crates/craft-status-app/src/added.rs
@@ -0,0 +1,735 @@
+//! Rows the poller adds to the Repos table on its own: each repo where the
+//! token's user has an open pull request, when the config lists another repo
+//! of the same owner ([`Config::may_add`]).
+//!
+//! Every `poll_minutes`, a round of searches finds them: one search per
+//! configured owner. The poll loop sends each search the way it sends a repo
+//! fetch: not during the gap after a fetch or a pause, not ahead of a row
+//! refreshed by hand, and not while fewer than [`OPTIONAL_FLOOR`] GraphQL
+//! points are left. The gap follows each search, and a rate limit backs off
+//! as after a fetch. An added row is removed once two rounds in a row find
+//! none of the user's open pull requests there. Only a row's Keep and Hide
+//! write the config file.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use chrono::{DateTime, Utc};
+use craft_core::config::RepoList;
+use craft_core::{Config, Snapshot};
+use craft_github::{resolve_token, Error as GhError, GitHub, OpenPrs};
+use tauri::{AppHandle, Manager};
+
+use super::{
+    clock, emit_snapshot, rate_limit_end, reload_config_if_changed, AppState, Pacing, BACKOFF_MAX,
+    BACKOFF_MIN, OPTIONAL_FLOOR, REPO_GAP, REPO_RETRY,
+};
+
+/// Rounds in a row that find no open pull request in an added repo before
+/// its row is removed.
+const MISSES: u32 = 2;
+
+/// What the poll thread keeps between searches.
+#[derive(Default)]
+pub struct Found {
+    /// The token the login was read with, and the login.
+    login: Option<(String, String)>,
+    /// Rounds in a row that missed each added repo, for those they missed.
+    misses: BTreeMap<String, u32>,
+    /// The round under way.
+    round: Option<Round>,
+    /// When the last round started.
+    ran_at: Option<DateTime<Utc>>,
+    /// No round starts before this.
+    next_at: Option<DateTime<Utc>>,
+}
+
+/// A round of searches, one per configured owner.
+#[derive(Debug, PartialEq)]
+struct Round {
+    /// The owners still to search, in config order.
+    owners: Vec<String>,
+    /// What the searches so far found.
+    open: OpenPrs,
+    /// The owners whose search failed or was not read to its end.
+    incomplete: Vec<String>,
+}
+
+/// A search that failed, by the request that failed.
+#[derive(Debug)]
+enum SearchError {
+    /// Reading the token, or the login of its user, failed.
+    Token(GhError),
+    /// The search of the owner's pull requests failed.
+    Owner(GhError),
+}
+
+impl SearchError {
+    fn error(&self) -> &GhError {
+        match self {
+            SearchError::Token(e) | SearchError::Owner(e) => e,
+        }
+    }
+}
+
+/// Keep: writes an added row's repo into the config's `repos`.
+#[tauri::command]
+pub fn keep_repo(app: AppHandle, repo: String) -> Result<(), String> {
+    write_list(&app, RepoList::Watched, &repo)
+}
+
+/// Hide: writes an added row's repo into the config's `hidden_auto_repos`,
+/// so it isn't added again.
+#[tauri::command]
+pub fn hide_repo(app: AppHandle, repo: String) -> Result<(), String> {
+    write_list(&app, RepoList::Hidden, &repo)
+}
+
+fn write_list(app: &AppHandle, list: RepoList, repo: &str) -> Result<(), String> {
+    let state = app.state::<AppState>();
+    let added = state.snapshot.lock().unwrap().added();
+    if !added.iter().any(|r| r == repo) {
+        return Err(format!("{repo} is not a row the app added"));
+    }
+    let written = Config::path().and_then(|path| Config::add_to_file(&path, list, repo));
+    if let Err(e) = written {
+        let msg = format!("Could not write the config: {e}");
+        state.snapshot.lock().unwrap().error = Some(msg.clone());
+        emit_snapshot(app);
+        return Err(msg);
+    }
+    reload_config_if_changed(app);
+    let cfg = state.config.lock().unwrap().clone();
+    if prune(&cfg, &mut state.snapshot.lock().unwrap()) {
+        emit_snapshot(app);
+    }
+    Ok(())
+}
+
+/// Called by the poll loop before each step: drops the added rows the config
+/// no longer allows, then sends the next search if one is due.
+pub fn discover(app: &AppHandle, pacing: &mut Pacing, found: &mut Found) {
+    let state = app.state::<AppState>();
+    let cfg = state.config.lock().unwrap().clone();
+    let now = Utc::now();
+    let (pruned, run) = {
+        let mut snap = state.snapshot.lock().unwrap();
+        let pruned = prune(&cfg, &mut snap);
+        (pruned, due(found, pacing, &cfg, &snap, now))
+    };
+    let owner = run.then(|| next_owner(found, &cfg, now)).flatten();
+    let Some(owner) = owner else {
+        if pruned {
+            emit_snapshot(app);
+        }
+        return;
+    };
+    let result = search(&cfg, found, &owner);
+    let now = Utc::now();
+    let msg = pace(pacing, result.as_ref().err().map(SearchError::error), now);
+    record(&state, found, &owner, result, msg, now);
+    emit_snapshot(app);
+}
+
+/// Puts the result of the search for `owner` into the snapshot and saves
+/// the cache. `msg` is the rate-limit message from [`pace`], if any.
+fn record(
+    state: &AppState,
+    found: &mut Found,
+    owner: &str,
+    result: Result<OpenPrs, SearchError>,
+    msg: Option<String>,
+    now: DateTime<Utc>,
+) {
+    let mut snap = state.snapshot.lock().unwrap();
+    // The config as Keep or Hide left it, if either wrote it during the
+    // search. They reload it before they take this lock to prune.
+    let cfg = state.config.lock().unwrap().clone();
+    let done = match result {
+        Ok(one) => {
+            if one.graphql_remaining.is_some() {
+                snap.rate_limit_remaining = one.graphql_remaining;
+            }
+            searched(found, one)
+        }
+        Err(SearchError::Owner(
+            e @ (GhError::GraphQl(_) | GhError::NotFound | GhError::Http(422, _)),
+        )) => {
+            eprintln!("open pull request search for {owner}: {e}");
+            failed(found)
+        }
+        Err(SearchError::Token(e) | SearchError::Owner(e)) => {
+            eprintln!("open pull request search for {owner}: {e}");
+            // The next round starts over, after the token problem `step`
+            // reports or a wait like a failed repo's.
+            found.round = None;
+            let wait = match e {
+                GhError::Unauthorized | GhError::NoToken => cfg.poll_secs(),
+                _ => REPO_RETRY,
+            };
+            found.next_at = Some(now + chrono::Duration::seconds(wait));
+            None
+        }
+    };
+    if let Some(round) = done {
+        apply_search(found, &cfg, &mut snap, &round.open, &round.incomplete);
+        found.next_at = Some(now + chrono::Duration::seconds(cfg.poll_secs()));
+    }
+    // Keep a config error up until the file is fixed.
+    let config_error = snap
+        .error
+        .as_deref()
+        .is_some_and(|e| e.starts_with("Config"));
+    if let (Some(msg), false) = (msg, config_error) {
+        snap.error = Some(msg);
+    }
+    if let Some(cache) = &state.cache {
+        if let Err(e) = cache.save(&snap) {
+            eprintln!("could not save cache: {e}");
+        }
+    }
+}
+
+/// The repos of `owner` where the token's user has an open pull request.
+/// The login is read once per token.
+fn search(cfg: &Config, found: &mut Found, owner: &str) -> Result<OpenPrs, SearchError> {
+    let token = resolve_token(&cfg.github_token).map_err(SearchError::Token)?;
+    let gh = GitHub::new(token.value.clone()).map_err(SearchError::Token)?;
+    let login = login(found, &token.value, || gh.token_login()).map_err(SearchError::Token)?;
+    gh.open_pr_repos(&login, owner).map_err(SearchError::Owner)
+}
+
+/// The owner to search next, starting a round over the configured owners
+/// when none is under way.
+fn next_owner(found: &mut Found, cfg: &Config, now: DateTime<Utc>) -> Option<String> {
+    if found.round.is_none() {
+        found.ran_at = Some(now);
+        found.round = Some(Round {
+            owners: cfg.owners(),
+            open: OpenPrs {
+                complete: true,
+                ..Default::default()
+            },
+            incomplete: Vec::new(),
+        });
+    }
+    let owner = found.round.as_ref()?.owners.first().cloned();
+    if owner.is_none() {
+        found.round = None;
+        found.next_at = Some(now + chrono::Duration::seconds(cfg.poll_secs()));
+    }
+    owner
+}
+
+/// Adds the next owner's search to the round, and the owner to the round's
+/// incomplete owners when the search was not read to its end. Returns the
+/// round once it has searched every owner.
+fn searched(found: &mut Found, one: OpenPrs) -> Option<Round> {
+    let round = found.round.as_mut()?;
+    if !round.owners.is_empty() {
+        let owner = round.owners.remove(0);
+        if !one.complete {
+            round.incomplete.push(owner);
+        }
+    }
+    round.open.repos.extend(one.repos);
+    round.open.graphql_remaining = one.graphql_remaining.or(round.open.graphql_remaining);
+    round.open.complete &= one.complete;
+    if round.owners.is_empty() {
+        found.round.take()
+    } else {
+        None
+    }
+}
+
+/// Ends the search for the round's next owner as incomplete, after an error
+/// GitHub gives again on every try for that owner, so the round goes on to
+/// the next owner and counts no misses for that owner's repos. Returns what
+/// [`searched`] returns.
+fn failed(found: &mut Found) -> Option<Round> {
+    searched(found, OpenPrs::default())
+}
+
+/// The login of the user `token` belongs to: the one held for that token, or
+/// else the one `read` returns, which is then held.
+fn login(
+    found: &mut Found,
+    token: &str,
+    read: impl FnOnce() -> Result<String, GhError>,
+) -> Result<String, GhError> {
+    if let Some((held, login)) = &found.login {
+        if held == token {
+            return Ok(login.clone());
+        }
+    }
+    let login = read()?;
+    found.login = Some((token.to_string(), login.clone()));
+    Ok(login)
+}
+
+/// Drops the added rows that `cfg` no longer allows (switched off, hidden,
+/// configured, or under an owner it no longer lists). True if it dropped any.
+fn prune(cfg: &Config, snap: &mut Snapshot) -> bool {
+    let added = snap.added();
+    let allowed: Vec<String> = added.iter().filter(|r| cfg.may_add(r)).cloned().collect();
+    if allowed.len() == added.len() {
+        return false;
+    }
+    snap.set_added(&allowed);
+    true
+}
+
+/// Whether the poll loop may search at `now`: the switch is on, pacing
+/// allows a request, no row refreshed by hand is waiting, at least
+/// [`OPTIONAL_FLOOR`] points are left, and a round is under way, due again,
+/// or older than a manual refresh.
+fn due(found: &Found, pacing: &Pacing, cfg: &Config, snap: &Snapshot, now: DateTime<Utc>) -> bool {
+    let budget = snap.rate_limit_remaining;
+    cfg.auto_repos
+        && !cfg.repos.is_empty()
+        && pacing.not_before.is_none_or(|t| now >= t)
+        && snap.requested.is_empty()
+        && budget.is_none_or(|r| r >= OPTIONAL_FLOOR)
+        && (found.round.is_some()
+            || found.next_at.is_none_or(|t| now >= t)
+            || pacing
+                .forced_since
+                .is_some_and(|f| found.ran_at.is_none_or(|r| r < f)))
+}
+
+/// Sets `pacing` after a search that failed with `error`, if it did, as
+/// `step` does after a fetch: the gap, or a backoff on a rate limit. Returns
+/// the message for the window.
+fn pace(pacing: &mut Pacing, error: Option<&GhError>, now: DateTime<Utc>) -> Option<String> {
+    let Some(GhError::RateLimited(until)) = error else {
+        pacing.strikes = 0;
+        pacing.not_before = Some(now + chrono::Duration::seconds(REPO_GAP));
+        return None;
+    };
+    pacing.strikes += 1;
+    let backoff = (BACKOFF_MIN << (pacing.strikes - 1).min(8)).min(BACKOFF_MAX);
+    let mut resume = now + chrono::Duration::seconds(backoff);
+    if let Some(end) = rate_limit_end(until, now) {
+        resume = resume.max(end);
+    }
+    pacing.not_before = Some(resume);
+    Some(format!(
+        "Rate limited by GitHub; pausing until {}",
+        clock(resume)
+    ))
+}
+
+/// Updates the added rows from a round that found `open`: a repo `cfg`
+/// allows gets a row, and an added row is removed on its [`MISSES`]th round
+/// in a row without an open pull request. A row under one of the `incomplete`
+/// owners, whose search failed or was not read to its end, counts no miss.
+fn apply_search(
+    found: &mut Found,
+    cfg: &Config,
+    snap: &mut Snapshot,
+    open: &OpenPrs,
+    incomplete: &[String],
+) {
+    let allowed: BTreeSet<&String> = open.repos.iter().filter(|r| cfg.may_add(r)).collect();
+    let mut keep = Vec::new();
+    let mut misses = BTreeMap::new();
+    for repo in snap.added().into_iter().filter(|r| cfg.may_add(r)) {
+        if allowed.iter().any(|o| o.eq_ignore_ascii_case(&repo)) {
+            keep.push(repo);
+            continue;
+        }
+        let owner = repo.split('/').next().unwrap_or_default();
+        let searched = !incomplete.iter().any(|o| o.eq_ignore_ascii_case(owner));
+        let held = found.misses.get(&repo).copied().unwrap_or(0);
+        let n = if searched { held + 1 } else { held };
+        if n < MISSES {
+            misses.insert(repo.clone(), n);
+            keep.push(repo);
+        }
+    }
+    found.misses = misses;
+    keep.extend(allowed.into_iter().cloned());
+    snap.set_added(&keep);
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use chrono::Duration as CDuration;
+    use std::sync::atomic::AtomicBool;
+    use std::sync::Mutex;
+
+    const CONFIG: &str = r#"repos = ["acme/one", "acme/two"]"#;
+
+    fn config(extra: &str) -> Config {
+        Config::parse(&format!("{CONFIG}\n{extra}")).unwrap()
+    }
+
+    fn snapshot(cfg: &Config) -> Snapshot {
+        let mut s = Snapshot::default();
+        s.sync_repos(&cfg.repos);
+        s
+    }
+
+    fn open(repos: &[&str]) -> OpenPrs {
+        OpenPrs {
+            repos: repos.iter().map(|r| r.to_string()).collect(),
+            graphql_remaining: Some(4000),
+            complete: true,
+        }
+    }
+
+    fn rows(s: &Snapshot) -> Vec<String> {
+        let mark = |auto| if auto { " (added)" } else { "" };
+        s.repos
+            .iter()
+            .map(|e| format!("{}{}", e.repo, mark(e.auto)))
+            .collect()
+    }
+
+    #[test]
+    fn a_search_adds_repos_under_a_configured_owner_only() {
+        let cfg = config(r#"hidden_auto_repos = ["acme/hidden"]"#);
+        let mut s = snapshot(&cfg);
+        let mut found = Found::default();
+        let search = open(&["acme/new", "stranger/fork", "ACME/one", "acme/hidden"]);
+        apply_search(&mut found, &cfg, &mut s, &search, &[]);
+        assert_eq!(rows(&s), ["acme/one", "acme/two", "acme/new (added)"]);
+    }
+
+    #[test]
+    fn a_round_searches_each_owner_in_a_step_of_its_own() {
+        let cfg = Config::parse(r#"repos = ["acme/one", "other-org/lib", "Acme/two"]"#).unwrap();
+        let s = snapshot(&cfg);
+        let now = Utc::now();
+        let mut found = Found::default();
+        let mut pacing = Pacing::default();
+        let first = next_owner(&mut found, &cfg, now);
+        assert_eq!((first.as_deref(), found.ran_at), (Some("acme"), Some(now)));
+        let done = searched(&mut found, open(&["acme/new"]));
+        assert_eq!(done, None, "a round goes on");
+        pace(&mut pacing, None, now);
+        let waits = !due(&found, &pacing, &cfg, &s, now);
+        assert!(waits, "the gap after each search");
+        found.next_at = Some(now + CDuration::seconds(cfg.poll_secs()));
+        let later = now + CDuration::seconds(REPO_GAP);
+        let goes_on = due(&found, &pacing, &cfg, &s, later);
+        assert!(goes_on, "a round under way goes on");
+        let second = next_owner(&mut found, &cfg, later);
+        assert_eq!(second.as_deref(), Some("other-org"));
+        assert_eq!(found.ran_at, Some(now), "the same round");
+        let cut_short = OpenPrs {
+            complete: false,
+            ..open(&["other-org/tool"])
+        };
+        let all = searched(&mut found, cut_short).unwrap();
+        let repos: Vec<&str> = all.open.repos.iter().map(String::as_str).collect();
+        assert_eq!(repos, ["acme/new", "other-org/tool"]);
+        assert!(!all.open.complete, "one search was cut short");
+        assert_eq!(all.incomplete, ["other-org"]);
+        assert!(found.round.is_none());
+    }
+
+    #[test]
+    fn an_added_row_goes_after_two_rounds_without_its_pull_request() {
+        let cfg = config("");
+        let mut s = snapshot(&cfg);
+        let mut found = Found::default();
+        let both = open(&["acme/new", "acme/other"]);
+        let other = open(&["acme/other"]);
+        apply_search(&mut found, &cfg, &mut s, &both, &[]);
+        s.repos[2].fetched_at = Some(Utc::now());
+        apply_search(&mut found, &cfg, &mut s, &other, &[]);
+        let all = [
+            "acme/one",
+            "acme/two",
+            "acme/new (added)",
+            "acme/other (added)",
+        ];
+        assert_eq!(rows(&s), all, "one miss keeps the row");
+        assert!(s.repos[2].fetched_at.is_some(), "and its data");
+        apply_search(&mut found, &cfg, &mut s, &both, &[]);
+        apply_search(&mut found, &cfg, &mut s, &other, &[]);
+        assert_eq!(rows(&s), all, "a found pull request starts the count again");
+        let cut_short = OpenPrs {
+            complete: false,
+            ..open(&["acme/other"])
+        };
+        let acme = ["acme".to_string()];
+        apply_search(&mut found, &cfg, &mut s, &cut_short, &acme);
+        assert_eq!(rows(&s), all, "a round cut short is no miss");
+        apply_search(&mut found, &cfg, &mut s, &other, &[]);
+        let left = ["acme/one", "acme/two", "acme/other (added)"];
+        assert_eq!(rows(&s), left, "two misses in a row");
+        apply_search(&mut found, &cfg, &mut s, &open(&[]), &[]);
+        apply_search(&mut found, &cfg, &mut s, &open(&[]), &[]);
+        assert_eq!(rows(&s), ["acme/one", "acme/two"]);
+    }
+
+    #[test]
+    fn with_auto_repos_off_nothing_is_added_or_searched() {
+        let on = config("");
+        let mut s = snapshot(&on);
+        let mut found = Found::default();
+        apply_search(&mut found, &on, &mut s, &open(&["acme/new"]), &[]);
+        let off = config("auto_repos = false");
+        s.sync_repos(&off.repos);
+        assert!(prune(&off, &mut s), "the switch drops the rows it added");
+        assert_eq!(rows(&s), ["acme/one", "acme/two"]);
+        apply_search(&mut found, &off, &mut s, &open(&["acme/new"]), &[]);
+        assert_eq!(rows(&s), ["acme/one", "acme/two"]);
+        assert!(!due(&found, &Pacing::default(), &off, &s, Utc::now()));
+    }
+
+    #[test]
+    fn kept_and_hidden_rows_follow_the_reloaded_config() {
+        let cfg = config("");
+        let mut s = snapshot(&cfg);
+        let mut found = Found::default();
+        let search = open(&["acme/kept", "acme/hidden"]);
+        apply_search(&mut found, &cfg, &mut s, &search, &[]);
+        let added = [
+            "acme/one",
+            "acme/two",
+            "acme/hidden (added)",
+            "acme/kept (added)",
+        ];
+        assert_eq!(rows(&s), added);
+        s.repos[3].fetched_at = Some(Utc::now());
+
+        // What Keep and Hide wrote, read back as the poll thread reads it.
+        let written = Config::parse(
+            r#"repos = ["acme/one", "acme/two", "acme/kept"]
+               hidden_auto_repos = ["acme/hidden"]"#,
+        )
+        .unwrap();
+        s.sync_repos(&written.repos);
+        assert!(prune(&written, &mut s));
+        assert_eq!(rows(&s), ["acme/one", "acme/two", "acme/kept"]);
+        assert!(s.repos[2].fetched_at.is_some(), "Keep keeps the data");
+        apply_search(&mut found, &written, &mut s, &search, &[]);
+        let kept = ["acme/one", "acme/two", "acme/kept"];
+        assert_eq!(rows(&s), kept, "the hidden repo stays out");
+    }
+
+    #[test]
+    fn a_search_that_ends_after_a_hide_uses_the_config_hide_wrote() {
+        let before = config("");
+        let mut found = Found::default();
+        let owner = next_owner(&mut found, &before, Utc::now()).unwrap();
+        // Hide wrote the config and pruned while the search was in flight.
+        let hidden = config(r#"hidden_auto_repos = ["acme/hidden"]"#);
+        let state = AppState {
+            config: Mutex::new(hidden),
+            config_mtime: Mutex::new(None),
+            snapshot: Mutex::new(snapshot(&before)),
+            cache: None,
+            refresh_tx: Mutex::new(None),
+            window_state: Mutex::new(None),
+            window_dirty: AtomicBool::new(false),
+        };
+        let result = Ok(open(&["acme/hidden", "acme/new"]));
+        record(&state, &mut found, &owner, result, None, Utc::now());
+        let s = state.snapshot.lock().unwrap();
+        assert_eq!(rows(&s), ["acme/one", "acme/two", "acme/new (added)"]);
+        assert_eq!(s.rate_limit_remaining, Some(4000));
+        assert!(found.round.is_none() && found.next_at.is_some());
+    }
+
+    #[test]
+    fn an_owner_whose_search_fails_does_not_stop_the_round() {
+        let cfg = Config::parse(r#"repos = ["acme/one", "other-org/lib"]"#).unwrap();
+        let mut s = snapshot(&cfg);
+        let mut found = Found::default();
+        apply_search(&mut found, &cfg, &mut s, &open(&["acme/old"]), &[]);
+        let state = AppState {
+            config: Mutex::new(cfg.clone()),
+            config_mtime: Mutex::new(None),
+            snapshot: Mutex::new(s),
+            cache: None,
+            refresh_tx: Mutex::new(None),
+            window_state: Mutex::new(None),
+            window_dirty: AtomicBool::new(false),
+        };
+        let now = Utc::now();
+        let refused = GhError::GraphQl("the listed users cannot be searched".into());
+        let invalid = GhError::Http(422, "Validation Failed".into());
+        for error in [refused, GhError::NotFound, invalid] {
+            let acme = next_owner(&mut found, &cfg, now).unwrap();
+            let result = Err(SearchError::Owner(error));
+            record(&state, &mut found, &acme, result, None, now);
+            assert!(found.round.is_some(), "the round goes on");
+            let other = next_owner(&mut found, &cfg, now).unwrap();
+            assert_eq!(other, "other-org");
+            let result = Ok(open(&["other-org/new"]));
+            record(&state, &mut found, &other, result, None, now);
+            assert!(found.round.is_none(), "the round ends");
+            let poll = Some(now + CDuration::seconds(cfg.poll_secs()));
+            assert_eq!(found.next_at, poll);
+        }
+        let all = [
+            "acme/one",
+            "other-org/lib",
+            "acme/old (added)",
+            "other-org/new (added)",
+        ];
+        let s = rows(&state.snapshot.lock().unwrap());
+        assert_eq!(s, all, "a round with a failed search is no miss");
+
+        let acme = next_owner(&mut found, &cfg, now).unwrap();
+        let down = SearchError::Owner(GhError::Http(502, "bad gateway".into()));
+        record(&state, &mut found, &acme, Err(down), None, now);
+        assert!(found.round.is_none(), "any other error starts over");
+        let retry = Some(now + CDuration::seconds(REPO_RETRY));
+        assert_eq!(found.next_at, retry);
+    }
+
+    #[test]
+    fn a_failed_login_read_starts_the_round_over() {
+        let cfg = Config::parse(r#"repos = ["acme/one", "other-org/lib"]"#).unwrap();
+        let mut s = snapshot(&cfg);
+        let mut found = Found::default();
+        apply_search(&mut found, &cfg, &mut s, &open(&["acme/old"]), &[]);
+        let state = AppState {
+            config: Mutex::new(cfg.clone()),
+            config_mtime: Mutex::new(None),
+            snapshot: Mutex::new(s),
+            cache: None,
+            refresh_tx: Mutex::new(None),
+            window_state: Mutex::new(None),
+            window_dirty: AtomicBool::new(false),
+        };
+        let now = Utc::now();
+        let no_login = GhError::GraphQl("no viewer.login".into());
+        let invalid = GhError::Http(422, "Validation Failed".into());
+        for error in [no_login, GhError::NotFound, invalid] {
+            let acme = next_owner(&mut found, &cfg, now).unwrap();
+            let result = Err(SearchError::Token(error));
+            record(&state, &mut found, &acme, result, None, now);
+            assert!(found.round.is_none(), "the round starts over");
+            let retry = Some(now + CDuration::seconds(REPO_RETRY));
+            assert_eq!(found.next_at, retry);
+        }
+        let s = rows(&state.snapshot.lock().unwrap());
+        let all = ["acme/one", "other-org/lib", "acme/old (added)"];
+        assert_eq!(s, all, "no round ended");
+    }
+
+    #[test]
+    fn a_miss_counts_only_where_the_owners_search_was_complete() {
+        let cfg = Config::parse(r#"repos = ["acme/one", "other-org/lib"]"#).unwrap();
+        let now = Utc::now();
+        for cut_short in [false, true] {
+            let mut s = snapshot(&cfg);
+            let mut found = Found::default();
+            let both = open(&["acme/old", "other-org/old"]);
+            apply_search(&mut found, &cfg, &mut s, &both, &[]);
+            let state = AppState {
+                config: Mutex::new(cfg.clone()),
+                config_mtime: Mutex::new(None),
+                snapshot: Mutex::new(s),
+                cache: None,
+                refresh_tx: Mutex::new(None),
+                window_state: Mutex::new(None),
+                window_dirty: AtomicBool::new(false),
+            };
+            for _ in 0..MISSES {
+                let acme = next_owner(&mut found, &cfg, now).unwrap();
+                let result = if cut_short {
+                    Ok(OpenPrs {
+                        complete: false,
+                        ..open(&[])
+                    })
+                } else {
+                    let refused = GhError::GraphQl("cannot be searched".into());
+                    Err(SearchError::Owner(refused))
+                };
+                record(&state, &mut found, &acme, result, None, now);
+                let other = next_owner(&mut found, &cfg, now).unwrap();
+                record(&state, &mut found, &other, Ok(open(&[])), None, now);
+                assert!(found.round.is_none(), "the round ends");
+            }
+            let s = rows(&state.snapshot.lock().unwrap());
+            let left = ["acme/one", "other-org/lib", "acme/old (added)"];
+            assert_eq!(s, left, "acme's search cut short: {cut_short}");
+        }
+    }
+
+    #[test]
+    fn a_search_waits_for_the_poll_loops_gap_pauses_and_budget() {
+        let cfg = config("");
+        let mut s = snapshot(&cfg);
+        let now = Utc::now();
+        let mut found = Found::default();
+        let mut pacing = Pacing::default();
+        let due_now =
+            |found: &Found, pacing: &Pacing, s: &Snapshot| due(found, pacing, &cfg, s, now);
+        assert!(due_now(&found, &pacing, &s), "the first search goes");
+
+        pacing.not_before = Some(now + CDuration::seconds(5));
+        assert!(!due_now(&found, &pacing, &s), "the gap or a pause");
+        pacing.not_before = None;
+
+        s.rate_limit_remaining = Some(OPTIONAL_FLOOR - 1);
+        assert!(!due_now(&found, &pacing, &s), "below the budget floor");
+        s.rate_limit_remaining = Some(OPTIONAL_FLOOR);
+        assert!(due_now(&found, &pacing, &s));
+
+        assert!(s.request("acme/one"));
+        assert!(!due_now(&found, &pacing, &s), "a hand refresh goes first");
+        s.take_requested();
+
+        found.ran_at = Some(now - CDuration::seconds(1));
+        found.next_at = Some(now + CDuration::seconds(cfg.poll_secs()));
+        assert!(!due_now(&found, &pacing, &s), "searched this poll_minutes");
+        pacing.forced_since = Some(now);
+        assert!(due_now(&found, &pacing, &s), "a manual refresh");
+    }
+
+    #[test]
+    fn the_login_is_read_once_per_token() {
+        let mut found = Found::default();
+        let mut reads = 0;
+        let mut read = |login: &str| {
+            reads += 1;
+            Ok::<_, GhError>(login.to_string())
+        };
+        let ann = login(&mut found, "t1", || read("ann-example")).unwrap();
+        assert_eq!(ann, "ann-example");
+        let again = login(&mut found, "t1", || read("unused")).unwrap();
+        assert_eq!(again, "ann-example", "held for the same token");
+        let bo = login(&mut found, "t2", || read("bo-example")).unwrap();
+        assert_eq!(bo, "bo-example", "read again for another token");
+        let failed = login(&mut found, "t3", || Err(GhError::Unauthorized));
+        assert!(matches!(failed, Err(GhError::Unauthorized)));
+        let held = login(&mut found, "t2", || read("unused")).unwrap();
+        assert_eq!(held, "bo-example", "a failed read keeps the held login");
+        assert_eq!(reads, 2);
+    }
+
+    #[test]
+    fn the_gap_follows_a_search_and_a_rate_limit_backs_off() {
+        let now: DateTime<Utc> = "2026-10-18T04:20:00Z".parse().unwrap();
+        let gap = Some(now + CDuration::seconds(REPO_GAP));
+        let mut pacing = Pacing {
+            strikes: 2,
+            ..Default::default()
+        };
+        assert_eq!(pace(&mut pacing, None, now), None);
+        assert_eq!((pacing.not_before, pacing.strikes), (gap, 0));
+
+        let network = GhError::Http(502, "bad gateway".into());
+        assert_eq!(pace(&mut pacing, Some(&network), now), None);
+        assert_eq!(pacing.not_before, gap);
+
+        let limited = GhError::RateLimited("2026-10-18T04:58:00Z".into());
+        let msg = pace(&mut pacing, Some(&limited), now).unwrap();
+        assert!(msg.starts_with("Rate limited by GitHub; pausing until "));
+        assert_eq!(pacing.strikes, 1);
+        let github = "2026-10-18T04:58:00Z".parse().ok();
+        assert_eq!(pacing.not_before, github, "GitHub's time is later");
+        let later = GhError::RateLimited("later".into());
+        pace(&mut pacing, Some(&later), now);
+        let doubled = Some(now + CDuration::seconds(2 * BACKOFF_MIN));
+        assert_eq!(pacing.not_before, doubled, "the backoff doubles");
+    }
+}
diff --git a/crates/craft-status-app/src/lib.rs b/crates/craft-status-app/src/lib.rs
--- a/crates/craft-status-app/src/lib.rs
+++ b/crates/craft-status-app/src/lib.rs
@@ -20,6 +20,8 @@ use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}
 use tauri::{AppHandle, Emitter, LogicalSize, Manager, State, WebviewWindow, Wry};
 use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
 
+mod added;
+
 const MAIN: &str = "main";
 const TRAY: &str = "main";
 const EV_SNAPSHOT: &str = "craft:snapshot";
@@ -41,6 +43,8 @@ const BACKOFF_MAX: i64 = 60 * 60;
 /// Below this many GraphQL points left, pause for `LOW_BUDGET_PAUSE`.
 const BUDGET_FLOOR: u64 = 150;
 const LOW_BUDGET_PAUSE: i64 = 30 * 60;
+/// Below this many GraphQL points left, skip requests nobody asked for.
+const OPTIONAL_FLOOR: u64 = 1_500;
 /// Ignore manual refreshes this soon after the previous one.
 const MANUAL_DEBOUNCE: i64 = 15;
 
@@ -472,8 +476,10 @@ fn spawn_poller(app: AppHandle, rx: mpsc::Receiver<Wake>) {
         .spawn(move || {
             let mut pacing = Pacing::default();
             let mut last_manual: Option<DateTime<Utc>> = None;
+            let mut found = added::Found::default();
             loop {
                 reload_config_if_changed(&app);
+                added::discover(&app, &mut pacing, &mut found);
                 step(&app, &mut pacing);
                 // Staleness changes with the clock alone.
                 update_tray(&app);
@@ -793,6 +799,8 @@ pub fn run() {
             hide_window,
             log,
             quit,
+            added::keep_repo,
+            added::hide_repo,
         ])
         .setup(move |app| {
             let cfg = app.state::<AppState>().config.lock().unwrap().clone();
diff --git a/ui/src/app.test.ts b/ui/src/app.test.ts
--- a/ui/src/app.test.ts
+++ b/ui/src/app.test.ts
@@ -276,6 +276,31 @@ test('the window picker switches the activity columns and remembers', async () =
   assert.equal(stored(h, 'window'), '7d');
 });
 
+test('a row the poller added is marked, and its Keep and Hide call the host', async () => {
+  const h = await setup({ snap: snapshot([repo('alpha', { urgency: 100 }), repo('extra', { urgency: 50 }, { auto: true })]) });
+  assert.equal(h.row('alpha').querySelector('.added'), null, 'a configured row has no marker');
+  const mark = h.row('extra').querySelector<HTMLElement>('.added-mark')!;
+  assert.equal(mark.title, 'Added because you have an open pull request here');
+  const actions = () => [...h.row('extra').querySelectorAll<HTMLButtonElement>('.added-action')];
+  assert.deepEqual(actions().map((b) => b.textContent), ['Keep', 'Hide']);
+
+  const [keep, hide] = actions();
+  pointer(h, keep!, 'pointerdown', 25);
+  pointer(h, h.win as unknown as EventTarget, 'pointermove', 60);
+  pointer(h, h.win as unknown as EventTarget, 'pointerup', 60);
+  keep!.click();
+  assert.deepEqual(h.rows(), ['alpha', 'extra'], 'not a drag');
+  assert.equal(h.view(), 'repos', 'not a click on the row');
+  assert.ok(keep!.disabled, 'once');
+  hide!.click();
+  const sent = h.calls.filter((c) => c.cmd.endsWith('_repo'));
+  assert.deepEqual(sent, [{ cmd: 'keep_repo', args: { repo: 'o/extra' } }, { cmd: 'hide_repo', args: { repo: 'o/extra' } }]);
+  assert.equal(stored(h, 'order'), null);
+
+  h.emit('craft:snapshot', snapshot([repo('alpha', { urgency: 100 }), repo('extra', { urgency: 50 })]));
+  assert.equal(h.row('extra').querySelector('.added'), null, 'kept: now a configured row');
+});
+
 test('an empty config says so', async () => {
   const h = await setup({ snap: snapshot([]) });
   assert.match(h.doc.getElementById('tbody')!.textContent ?? '', /No repositories configured/);
diff --git a/ui/src/app.ts b/ui/src/app.ts
--- a/ui/src/app.ts
+++ b/ui/src/app.ts
@@ -100,6 +100,25 @@ export function start(api: Api): () => void {
   const link = (text: string, url: string, cls = ''): HTMLElement =>
     el('span', { class: `link ${cls}`, onclick: (e: Event) => openUrl(url, e) }, text);
 
+  /** The marker of a row the poller added, with Keep and Hide, which write the config file. */
+  function addedControls(entry: RepoEntry): HTMLElement {
+    const action = (label: string, cmd: string, title: string) => el('button', {
+      class: 'added-action',
+      title,
+      // Stop the press from starting a row drag or opening the urgent view.
+      onpointerdown: (e: Event) => e.stopPropagation(),
+      onclick: (e: Event) => {
+        e.stopPropagation();
+        (e.currentTarget as HTMLButtonElement).disabled = true;
+        api.invoke(cmd, { repo: entry.repo }).catch((err: unknown) => console.warn(err));
+      },
+    }, label);
+    return el('span', { class: 'added' },
+      el('span', { class: 'added-mark', title: 'Added because you have an open pull request here' }, 'added'),
+      action('Keep', 'keep_repo', `Keep ${shortName(entry.repo)}: add it to repos in the config file`),
+      action('Hide', 'hide_repo', `Hide ${shortName(entry.repo)}: add it to hidden_auto_repos in the config file, so it isn’t added again`));
+  }
+
   /** The repo's app icon; repos without one (libraries) get a lettered tile, unfetched ones a blank. */
   function appIcon(entry: Pick<RepoEntry, 'repo'> & Partial<RepoEntry>): HTMLElement {
     if (entry.icon) return el('img', { class: 'app-icon', src: entry.icon, alt: '' });
@@ -116,7 +135,8 @@ export function start(api: Api): () => void {
         ? `Last attempt failed: ${entry.error}`
         : entry.inaccessible ? 'Not accessible (private, or not found)'
         : entry.fetched_at ? `Fetched ${localTime(entry.fetched_at)}` : 'Not fetched yet';
-      return el('span', { class: 'repo', title: tip }, el('span', { class: 'dot' }), appIcon(entry), link(String(v), gh(entry.repo)));
+      return el('span', { class: 'repo', title: tip }, el('span', { class: 'dot' }), appIcon(entry), link(String(v), gh(entry.repo)),
+        entry.auto ? addedControls(entry) : null);
     }
     if (col.key === 'fetched') {
       const age = v ? el('span', { title: `Fetched ${localTime(String(v))}` }, ago(String(v))) : el('span', { class: 'muted', title: 'Not fetched yet' }, '—');
diff --git a/ui/src/model.ts b/ui/src/model.ts
--- a/ui/src/model.ts
+++ b/ui/src/model.ts
@@ -62,6 +62,8 @@ export interface RepoEntry {
   fetched_at: Time | null;
   last_attempt_at?: Time | null;
   error?: string | null;
+  /** The poller added the row: the token's user has an open pull request there (absent from older builds). */
+  auto?: boolean;
   inaccessible?: boolean;
   icon?: string | null;
   icon_oid?: string | null;
diff --git a/ui/style.css b/ui/style.css
--- a/ui/style.css
+++ b/ui/style.css
@@ -112,6 +112,11 @@ tr.stale td:not(:first-child) { opacity: .55; }
 .row-refresh:hover, .row-refresh.spinning { opacity: 1; color: var(--accent); }
 .row-refresh.spinning svg { animation: spin 1s linear infinite; }
 @media (prefers-reduced-motion: reduce) { .row-refresh.spinning svg { animation: none; } }
+.added { display: inline-flex; align-items: center; gap: 3px; font-weight: 400; }
+.added-mark { font-size: 9px; font-weight: 600; color: var(--accent); padding: 0 5px; border-radius: 7px; background: color-mix(in srgb, var(--accent) 14%, transparent); }
+.added-action { border: 0; background: none; padding: 0 2px; font-size: 10px; color: var(--muted); opacity: .7; }
+.added-action:hover:not(:disabled) { opacity: 1; color: var(--accent); text-decoration: underline; }
+.added-action:disabled { opacity: .35; cursor: default; }
 .zero { color: var(--muted); opacity: .6; }
 .active { color: var(--accent); font-weight: 600; }
 .link:hover { text-decoration: underline; color: var(--accent); }
