use std::collections::BTreeMap;
use anyhow::Result;
use wire::{TranscriptAttachmentRef, UsageTotals};
use crate::attribution::clean_attribution_value;
mod claude_code;
mod codex;
mod opencode;
pub(crate) use claude_code::ClaudeCodeProbe;
pub(crate) use codex::CodexProbe;
pub(crate) use opencode::OpenCodeProbe;
#[derive(Debug, Clone, Default)]
pub(crate) struct HarnessProbeInput {
pub argv: Option<Vec<String>>,
pub env_hints: BTreeMap<String, String>,
pub explicit_harness: Option<String>,
pub explicit_provider: Option<String>,
pub explicit_model: Option<String>,
pub explicit_thinking_level: Option<String>,
pub explicit_policy: Option<String>,
pub probe_metadata: BTreeMap<String, String>,
pub current_provider: Option<String>,
pub current_model: Option<String>,
pub current_policy: Option<String>,
#[allow(dead_code)]
pub repo_root: String,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct HarnessAttachHints {
pub root_actor: bool,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct HarnessProbeResult {
pub harness: Option<String>,
pub provider: Option<String>,
pub model: Option<String>,
pub thinking_level: Option<String>,
pub policy: Option<String>,
pub native_actor_key: Option<String>,
pub native_parent_actor_key: Option<String>,
pub native_instance_key: Option<String>,
#[allow(dead_code)]
pub thread_hint: Option<String>,
pub usage_totals: UsageTotals,
pub touched_paths: Vec<String>,
pub transcript_refs: Vec<TranscriptAttachmentRef>,
pub attach_hints: HarnessAttachHints,
pub confidence: Option<f32>,
pub probe_source: Option<String>,
}
pub(crate) trait HarnessActorProbe {
fn harness_name(&self) -> &'static str;
fn matches(&self, input: &HarnessProbeInput) -> bool;
fn probe(&self, input: &HarnessProbeInput) -> Result<HarnessProbeResult>;
}
pub(crate) fn probe_harness_actor(input: &HarnessProbeInput) -> Result<HarnessProbeResult> {
let probes: [&dyn HarnessActorProbe; 3] = [&CodexProbe, &OpenCodeProbe, &ClaudeCodeProbe];
if let Some(explicit) = input.explicit_harness.as_deref()
&& let Some(probe) = probes
.into_iter()
.find(|probe| probe.harness_name() == explicit)
{
return probe.probe(input);
}
let probes: [&dyn HarnessActorProbe; 3] = [&CodexProbe, &OpenCodeProbe, &ClaudeCodeProbe];
if let Some(probe) = probes.into_iter().find(|probe| probe.matches(input)) {
return probe.probe(input);
}
Ok(generic_probe(input))
}
fn generic_probe(input: &HarnessProbeInput) -> HarnessProbeResult {
let fingerprint = fingerprint_from_hints(input.argv.as_deref(), &input.env_hints);
HarnessProbeResult {
harness: input.explicit_harness.clone().or(fingerprint.harness),
provider: input
.explicit_provider
.clone()
.or(fingerprint.provider)
.or_else(|| input.current_provider.clone()),
model: input
.explicit_model
.clone()
.or(fingerprint.model)
.or_else(|| input.current_model.clone()),
thinking_level: input
.explicit_thinking_level
.clone()
.or(fingerprint.thinking_level),
policy: input
.explicit_policy
.clone()
.or(fingerprint.policy)
.or_else(|| input.current_policy.clone()),
confidence: Some(if input.explicit_harness.is_some() {
1.0
} else {
0.4
}),
probe_source: Some(if input.explicit_harness.is_some() {
ProbeSource::ExplicitPayload.as_str().to_string()
} else {
ProbeSource::ArgvEnv.as_str().to_string()
}),
..HarnessProbeResult::default()
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum ProbeSource {
ExplicitPayload,
AppProtocol,
HookPayload,
StatusPayload,
SseOrRest,
ArgvEnv,
#[allow(dead_code)]
ConfigOverride,
}
impl ProbeSource {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::ExplicitPayload => "explicit_payload",
Self::AppProtocol => "app_protocol",
Self::HookPayload => "hook_payload",
Self::StatusPayload => "status_payload",
Self::SseOrRest => "sse_or_rest",
Self::ArgvEnv => "argv_env",
Self::ConfigOverride => "config_override",
}
}
}
#[derive(Default)]
struct FingerprintedIdentity {
harness: Option<String>,
provider: Option<String>,
model: Option<String>,
thinking_level: Option<String>,
policy: Option<String>,
}
fn fingerprint_from_hints(
argv: Option<&[String]>,
env_hints: &BTreeMap<String, String>,
) -> FingerprintedIdentity {
let mut fingerprint = FingerprintedIdentity::default();
let program = argv
.and_then(|args| args.first())
.map(|arg| arg.to_ascii_lowercase())
.unwrap_or_default();
if program.contains("claude")
|| env_hints.contains_key("CLAUDECODE")
|| env_hints.contains_key("CLAUDE_CODE")
{
fingerprint.harness = Some("claude-code".to_string());
fingerprint.provider = Some("anthropic".to_string());
} else if program.contains("codex")
|| env_hints.contains_key("CODEX_SANDBOX")
|| env_hints.contains_key("CODEX_THREAD_ID")
|| env_hints.contains_key("CODEX_CI")
{
fingerprint.harness = Some("codex".to_string());
fingerprint.provider = Some("openai".to_string());
} else if program.contains("opencode") || env_hints.contains_key("OPENCODE_CLIENT") {
fingerprint.harness = Some("opencode".to_string());
} else if program.contains("aider") {
fingerprint.harness = Some("aider".to_string());
}
fingerprint.provider = fingerprint.provider.or_else(|| {
env_hints
.get("HEDDLE_AGENT_PROVIDER")
.cloned()
.and_then(clean_attribution_value)
});
fingerprint.model = env_hints
.get("HEDDLE_AGENT_MODEL")
.cloned()
.and_then(clean_attribution_value)
.or_else(|| env_hints.get("CODEX_MODEL").cloned())
.or_else(|| env_hints.get("CLAUDE_MODEL").cloned())
.or_else(|| env_hints.get("ANTHROPIC_MODEL").cloned())
.or_else(|| env_hints.get("OPENAI_MODEL").cloned())
.or_else(|| env_hints.get("OPENCODE_MODEL").cloned())
.or_else(|| env_hints.get("AIDER_MODEL").cloned())
.or_else(|| env_hints.get("MODEL").cloned());
fingerprint.thinking_level = env_hints
.get("THINKING_LEVEL")
.cloned()
.or_else(|| env_hints.get("CODEX_REASONING_EFFORT").cloned())
.or_else(|| env_hints.get("REASONING_EFFORT").cloned())
.or_else(|| env_hints.get("OPENAI_REASONING_EFFORT").cloned());
fingerprint.policy = env_hints
.get("HEDDLE_AGENT_POLICY")
.cloned()
.and_then(clean_attribution_value)
.or_else(|| env_hints.get("PROMPT_POLICY").cloned());
fingerprint
}
pub(crate) fn attribution_env_hint(
env_hints: &BTreeMap<String, String>,
key: &str,
) -> Option<String> {
env_hints
.get(key)
.cloned()
.and_then(clean_attribution_value)
}
pub(crate) fn argv_value(argv: &[String], flag: &str) -> Option<String> {
let mut iter = argv.iter();
while let Some(arg) = iter.next() {
if arg == flag {
return iter.next().cloned();
}
if let Some(value) = arg.strip_prefix(&(flag.to_string() + "=")) {
return Some(value.to_string());
}
}
None
}
pub(crate) fn csv_paths(value: Option<&String>) -> Vec<String> {
value
.map(|raw| {
raw.split(',')
.map(|path| path.trim().replace('\\', "/"))
.filter(|path| !path.is_empty())
.collect()
})
.unwrap_or_default()
}
pub(crate) fn parse_u64(value: Option<&String>) -> Option<u64> {
value.and_then(|v| v.parse().ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codex_thread_env_identifies_codex_actor() {
let mut env_hints = BTreeMap::new();
env_hints.insert("CODEX_THREAD_ID".to_string(), "thread-123".to_string());
env_hints.insert("CODEX_MODEL".to_string(), "gpt-5.5".to_string());
env_hints.insert("CODEX_REASONING_EFFORT".to_string(), "xhigh".to_string());
let result = probe_harness_actor(&HarnessProbeInput {
env_hints,
repo_root: "/tmp/repo".to_string(),
..HarnessProbeInput::default()
})
.expect("probe should succeed");
assert_eq!(result.harness.as_deref(), Some("codex"));
assert_eq!(result.provider.as_deref(), Some("openai"));
assert_eq!(result.model.as_deref(), Some("gpt-5.5"));
assert_eq!(result.thinking_level.as_deref(), Some("xhigh"));
assert_eq!(
result.native_actor_key.as_deref(),
Some("codex:thread:thread-123")
);
}
#[test]
fn codex_current_provider_wins_before_default_provider() {
let mut env_hints = BTreeMap::new();
env_hints.insert("CODEX_THREAD_ID".to_string(), "thread-123".to_string());
env_hints.insert("OPENAI_MODEL".to_string(), "gpt-5.3-codex".to_string());
let result = probe_harness_actor(&HarnessProbeInput {
env_hints,
current_provider: Some("openai-compatible".to_string()),
repo_root: "/tmp/repo".to_string(),
..HarnessProbeInput::default()
})
.expect("probe should succeed");
assert_eq!(result.harness.as_deref(), Some("codex"));
assert_eq!(result.provider.as_deref(), Some("openai-compatible"));
assert_eq!(result.model.as_deref(), Some("gpt-5.3-codex"));
}
#[test]
fn generic_harness_probe_keeps_heddle_agent_env_identity() {
let mut env_hints = BTreeMap::new();
env_hints.insert("HEDDLE_AGENT_PROVIDER".to_string(), "custom-ai".to_string());
env_hints.insert("HEDDLE_AGENT_MODEL".to_string(), "custom-model".to_string());
let result = probe_harness_actor(&HarnessProbeInput {
env_hints,
repo_root: "/tmp/repo".to_string(),
..HarnessProbeInput::default()
})
.expect("probe should succeed");
assert_eq!(result.harness, None);
assert_eq!(result.provider.as_deref(), Some("custom-ai"));
assert_eq!(result.model.as_deref(), Some("custom-model"));
}
#[test]
fn explicit_heddle_agent_env_wins_over_detected_claude_identity() {
let mut env_hints = BTreeMap::new();
env_hints.insert("CLAUDECODE".to_string(), "1".to_string());
env_hints.insert("HEDDLE_AGENT_PROVIDER".to_string(), "openai".to_string());
env_hints.insert("HEDDLE_AGENT_MODEL".to_string(), "gpt-5-codex".to_string());
let result = probe_harness_actor(&HarnessProbeInput {
env_hints,
probe_metadata: BTreeMap::from([(
"model".to_string(),
"claude-opus-4-8[1m]".to_string(),
)]),
repo_root: "/tmp/repo".to_string(),
..HarnessProbeInput::default()
})
.expect("probe should succeed");
assert_eq!(result.harness.as_deref(), Some("claude-code"));
assert_eq!(result.provider.as_deref(), Some("openai"));
assert_eq!(result.model.as_deref(), Some("gpt-5-codex"));
}
#[test]
fn blank_heddle_agent_env_falls_through_to_detected_claude_identity() {
let mut env_hints = BTreeMap::new();
env_hints.insert("CLAUDECODE".to_string(), "1".to_string());
env_hints.insert("HEDDLE_AGENT_PROVIDER".to_string(), "anthropic".to_string());
env_hints.insert("HEDDLE_AGENT_MODEL".to_string(), String::new());
env_hints.insert("HEDDLE_AGENT_POLICY".to_string(), "unknown".to_string());
let result = probe_harness_actor(&HarnessProbeInput {
env_hints,
explicit_harness: Some("claude-code".to_string()),
probe_metadata: BTreeMap::from([
("model".to_string(), "claude-opus-4-8[1m]".to_string()),
("session_id".to_string(), "claude-sess-1".to_string()),
]),
current_policy: Some("detected-policy".to_string()),
repo_root: "/tmp/repo".to_string(),
..HarnessProbeInput::default()
})
.expect("probe should succeed");
assert_eq!(result.harness.as_deref(), Some("claude-code"));
assert_eq!(result.provider.as_deref(), Some("anthropic"));
assert_eq!(result.model.as_deref(), Some("claude-opus-4-8[1m]"));
assert_eq!(result.policy.as_deref(), Some("detected-policy"));
}
#[test]
fn argv_parent_hint_identifies_claude_code_actor() {
let result = probe_harness_actor(&HarnessProbeInput {
argv: Some(vec![
"/home/user/.local/bin/claude".to_string(),
"--model".to_string(),
"claude-opus-4-7".to_string(),
]),
repo_root: "/tmp/repo".to_string(),
..HarnessProbeInput::default()
})
.expect("probe should succeed");
assert_eq!(result.harness.as_deref(), Some("claude-code"));
assert_eq!(result.provider.as_deref(), Some("anthropic"));
assert_eq!(result.model.as_deref(), Some("claude-opus-4-7"));
}
#[test]
fn claude_code_env_model_fills_detected_harness_identity() {
let mut env_hints = BTreeMap::new();
env_hints.insert("CLAUDECODE".to_string(), "1".to_string());
env_hints.insert(
"HEDDLE_AGENT_MODEL".to_string(),
"claude-opus-4-7".to_string(),
);
env_hints.insert("THINKING_LEVEL".to_string(), "xhigh".to_string());
let result = probe_harness_actor(&HarnessProbeInput {
env_hints,
current_provider: Some("anthropic".to_string()),
repo_root: "/tmp/repo".to_string(),
..HarnessProbeInput::default()
})
.expect("probe should succeed");
assert_eq!(result.harness.as_deref(), Some("claude-code"));
assert_eq!(result.provider.as_deref(), Some("anthropic"));
assert_eq!(result.model.as_deref(), Some("claude-opus-4-7"));
assert_eq!(result.thinking_level.as_deref(), Some("xhigh"));
}
#[test]
fn opencode_env_model_fills_detected_harness_identity() {
let mut env_hints = BTreeMap::new();
env_hints.insert("OPENCODE_CLIENT".to_string(), "desktop".to_string());
env_hints.insert("OPENCODE_PROVIDER".to_string(), "anthropic".to_string());
env_hints.insert(
"OPENCODE_MODEL".to_string(),
"claude-sonnet-4-6".to_string(),
);
let result = probe_harness_actor(&HarnessProbeInput {
env_hints,
repo_root: "/tmp/repo".to_string(),
..HarnessProbeInput::default()
})
.expect("probe should succeed");
assert_eq!(result.harness.as_deref(), Some("opencode"));
assert_eq!(result.provider.as_deref(), Some("anthropic"));
assert_eq!(result.model.as_deref(), Some("claude-sonnet-4-6"));
}
}