fix: détection externe — champ detect (sonde PATH explicite) pour les lanceurs interpréteurs

lazycodex (run: npx lazycodex-ai) et nanobot (run: python -m nanobot)
étaient déclarés « external » dès que npx/python étaient sur le PATH :
la sonde utilisait le premier mot de run au lieu du vrai binaire.

- AgentDef.detect : sonde PATH explicite, repli sur first_token sinon
- probe.rs : cache invalidé quand les tokens du catalogue changent
  (token_hash), plus besoin d'attendre un changement de PATH
- config.yaml : detect: lazycodex-ai / detect: nanobot
- tests : detect_token_prefers_explicit_binary + 432 tests verts
- v1.0.3, man pages régénérées
This commit is contained in:
2026-08-20 10:53:59 -04:00
parent 79e032b563
commit ee490f5eaf
10 changed files with 88 additions and 11 deletions
+33 -5
View File
@@ -79,6 +79,11 @@ struct ProbeCache {
/// FNV-1a hash of the PATH string the token index was built from.
#[serde(default)]
path_hash: u64,
/// FNV-1a hash of the catalog's detect tokens. When the catalog changes
/// which binaries it probes (new agent, `detect` added), the token
/// index must be rebuilt even if the PATH is unchanged.
#[serde(default)]
token_hash: u64,
/// Catalog run-command first token -> resolved path (null = not found).
#[serde(default)]
tokens: BTreeMap<String, Option<String>>,
@@ -98,6 +103,26 @@ fn path_hash() -> u64 {
h
}
/// Hash of the sorted catalog detect tokens: cache invalidation for catalog
/// changes (new agent, added `detect` field) without re-scanning the PATH.
fn catalog_token_hash(app: &App) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut tokens: Vec<String> = app
.catalog
.agents()
.iter()
.filter_map(|a| a.detect_token())
.collect();
tokens.sort();
for t in tokens {
for b in t.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
h
}
/// Build an index of every executable on the PATH in a single pass.
/// Much cheaper than one which() call per agent when the catalog is large.
fn build_path_index() -> BTreeMap<String, PathBuf> {
@@ -163,13 +188,15 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
.unwrap_or_default();
let mut dirty = false;
// Resolve the run-command tokens of the catalog (once per PATH change).
// Resolve the detect tokens of the catalog (once per PATH change or
// catalog change).
let current_hash = path_hash();
if cache.path_hash != current_hash {
let current_tokens = catalog_token_hash(app);
if cache.path_hash != current_hash || cache.token_hash != current_tokens {
let index = build_path_index();
cache.tokens.clear();
for agent in app.catalog.agents() {
let Some(token) = agent.first_token() else {
let Some(token) = agent.detect_token() else {
continue;
};
if !cache.tokens.contains_key(&token) {
@@ -180,6 +207,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
}
}
cache.path_hash = current_hash;
cache.token_hash = current_tokens;
dirty = true;
}
@@ -195,7 +223,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
let index = build_path_index();
cache.tokens.clear();
for agent in app.catalog.agents() {
let Some(token) = agent.first_token() else {
let Some(token) = agent.detect_token() else {
continue;
};
if !cache.tokens.contains_key(&token) {
@@ -211,7 +239,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo {
if agent.hidden || !agent.installable || managed.contains_key(&agent.name) {
continue;
}
let Some(token) = agent.first_token() else {
let Some(token) = agent.detect_token() else {
continue;
};
if let Some(Some(path)) = cache.tokens.get(&token) {