Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/release-please-config.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"plugins": ["cargo-workspace"],
"packages": {
".": {
"release-type": "node",
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ jobs:
- name: Install npm dependencies
run: npm ci

- name: Check Cargo.lock is current
run: cargo metadata --locked --format-version 1 > /dev/null

- name: Run checks
run: npm run check

Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/native-binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ jobs:
binary: agenthint
asset: agenthint-linux-x64
target: ""
- name: linux-arm64
os: ubuntu-24.04-arm
binary: agenthint
asset: agenthint-linux-arm64
target: ""
- name: macos-arm64
os: macos-latest
binary: agenthint
Expand Down
2 changes: 1 addition & 1 deletion .mise.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[tools]
node = "lts"
rust = "stable"

python = "3.12"
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ Every detection result includes:
Detection priority:

1. `AGENTHINT_DISABLE`
2. `AGENTHINT_FORCE`
2. `AGENTHINT_FORCE` (optionally named via `AGENTHINT_AGENT`)
3. Explicit `AI_AGENT`
4. Known environment signals
5. Documented filesystem signals
Expand All @@ -193,6 +193,8 @@ Detection priority:

Known agents include Codex, Claude Code, Cursor, Gemini CLI, Aider, Augment CLI, AMP, OpenCode, OpenClaw, GitHub Copilot, Replit, Devin, Google Antigravity, Pi, Kiro CLI, Windsurf, Cline, Roo Code, Kilo Code, Mistral Vibe, v0, and Cowork.

Some signals are weak. For example `REPL_ID` is present in every Replit workspace, including human-driven sessions, so it reports a low confidence. Exit-code consumers that want to avoid false positives can read `confidence` from `agenthint --json` and apply their own threshold.

Custom agents are supported through any non-empty `AI_AGENT` value.

## Docs
Expand Down
15 changes: 14 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ AI_AGENT=my-custom-agent my-tool

`AI_AGENT` should be checked before heuristic signals. Empty and whitespace-only values should be ignored.

## Override Conventions

`AGENTHINT_DISABLE` forces a no-agent result and `AGENTHINT_FORCE` forces an agent result; both take precedence over `AI_AGENT` and heuristics. When `AGENTHINT_FORCE` is set, `AGENTHINT_AGENT` optionally names the forced agent; otherwise the agent is `unknown`. Truthy values are `1`, `true`, `yes`, and `on`.

## Signal Ordering and Ties

To keep results portable across implementations:

- Heuristic matches report every matching signal, deduplicated, in rule-registry order.
- Signal names matched by an environment prefix rule are sorted lexicographically.
- When multiple heuristic rules match with equal confidence, the earliest rule in the registry wins.

## Parent Process Signals

Implementations may inspect the direct parent process name as a low-confidence heuristic. Parent process checks should be configurable because process names can be unavailable, ambiguous, platform-specific, or controlled by wrappers.
Expand Down Expand Up @@ -80,11 +92,12 @@ Initial candidates:
- `KILOCODE_AGENT`
- `OPENCLAW_AGENT`
- `AGENTHINT_FORCE`
- `AGENTHINT_AGENT`
- `AGENTHINT_DISABLE`

Implementations must not print environment variable values by default. Signal names are enough for diagnostics.

`CLAUDE_CODE_IS_COWORK` is a classifier only. It may select `cowork` when another Claude signal is present, but should not be treated as an agent signal by itself.
`CLAUDE_CODE_IS_COWORK` is a classifier only. It may select `cowork` when another Claude signal is present, but should not be treated as an agent signal by itself. When it does select `cowork`, `env:CLAUDE_CODE_IS_COWORK` is included in `signals` to keep the classification explainable.

Known agent names without stable heuristic signals should still be supported through `AI_AGENT`.
Current explicit-only known names:
Expand Down
110 changes: 63 additions & 47 deletions crates/agenthint/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,18 +78,25 @@ pub fn detect_agent_with_options(options: DetectAgentOptions) -> AgentHintResult
}

let matches = detection_matches(&options.env);
if let Some(best) = matches
.iter()
.max_by(|left, right| left.confidence.total_cmp(&right.confidence))
{
if let Some(best) = matches.iter().reduce(|best, current| {
if current.confidence > best.confidence {
current
} else {
best
}
}) {
let mut signals: Vec<String> = Vec::new();
for signal in matches.iter().flat_map(|agent_match| &agent_match.signals) {
if !signals.contains(signal) {
signals.push(signal.clone());
}
}

return AgentHintResult {
is_agent: true,
agent: Some(best.agent.clone()),
confidence: best.confidence,
signals: matches
.iter()
.flat_map(|agent_match| agent_match.signals.clone())
.collect(),
signals,
};
}

Expand Down Expand Up @@ -219,7 +226,9 @@ fn doctor_setup_json(result: &AgentHintResult) -> String {
}

pub fn format_init(agent: Option<&str>) -> String {
let Some(agent) = normalize_agent_name(agent.map(|value| value.to_string()).as_ref()) else {
let Some(agent) = normalize_agent_name(agent.map(|value| value.to_string()).as_ref())
.filter(|agent| !agent.starts_with('-'))
else {
return [
"agenthint init",
"",
Expand Down Expand Up @@ -293,15 +302,25 @@ fn detection_matches(env: &HashMap<String, String>) -> Vec<AgentMatch> {
let mut matches = Vec::new();

for rule in generated_rules::ENVIRONMENT_RULES {
let agent = if rule.agent == "claude-code"
&& !present(env, &["CLAUDE_CODE_IS_COWORK"]).is_empty()
{
"cowork"
} else {
rule.agent
};
let mut signals = present(env, rule.names);
if signals.is_empty() {
continue;
}

push_present(&mut matches, env, agent, rule.confidence, rule.names);
let mut agent = rule.agent;
if rule.agent == "claude-code" {
let cowork_signals = present(env, &["CLAUDE_CODE_IS_COWORK"]);
if !cowork_signals.is_empty() {
agent = "cowork";
signals.extend(cowork_signals);
}
}

matches.push(AgentMatch {
agent: agent.to_string(),
confidence: rule.confidence,
signals,
});
}

for rule in generated_rules::PREFIX_RULES {
Expand Down Expand Up @@ -332,22 +351,13 @@ fn from_parent_process(options: &DetectAgentOptions) -> Option<AgentHintResult>
}

fn parent_process_name() -> Option<String> {
let ppid_output = Command::new("ps")
.args(["-o", "ppid=", "-p", &std::process::id().to_string()])
.output()
.ok()?;

if !ppid_output.status.success() {
return None;
}

let ppid = String::from_utf8(ppid_output.stdout).ok()?;
let ppid = ppid.trim();
let ppid = parent_pid()?;

if ppid.is_empty() {
if ppid == 0 {
return None;
}

let ppid = ppid.to_string();
let proc_path = format!("/proc/{ppid}/comm");

if let Ok(value) = std::fs::read_to_string(proc_path) {
Expand All @@ -358,7 +368,7 @@ fn parent_process_name() -> Option<String> {
}

let output = Command::new("ps")
.args(["-o", "comm=", "-p", ppid])
.args(["-o", "comm=", "-p", ppid.as_str()])
.output()
.ok()?;

Expand All @@ -376,6 +386,16 @@ fn parent_process_name() -> Option<String> {
}
}

#[cfg(unix)]
fn parent_pid() -> Option<u32> {
Some(std::os::unix::process::parent_id())
}

#[cfg(not(unix))]
fn parent_pid() -> Option<u32> {
None
}

fn normalize_process_name(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
Expand All @@ -395,23 +415,6 @@ fn agent_from_process_name(name: &str) -> Option<&'static str> {
.map(|rule| rule.agent)
}

fn push_present(
matches: &mut Vec<AgentMatch>,
env: &HashMap<String, String>,
agent: &str,
confidence: f32,
names: &[&str],
) {
let signals = present(env, names);
if !signals.is_empty() {
matches.push(AgentMatch {
agent: agent.to_string(),
confidence,
signals,
});
}
}

fn push_prefix(
matches: &mut Vec<AgentMatch>,
env: &HashMap<String, String>,
Expand Down Expand Up @@ -755,9 +758,21 @@ mod tests {
let not_cowork = detect(env(&[("CLAUDE_CODE_IS_COWORK", "1")]));

assert_eq!(cowork.agent.as_deref(), Some("cowork"));
assert_eq!(
cowork.signals,
vec!["env:CLAUDE_CODE", "env:CLAUDE_CODE_IS_COWORK"]
);
assert!(!not_cowork.is_agent);
}

#[test]
fn equal_confidence_ties_prefer_the_earlier_rule() {
let result = detect(env(&[("GEMINI_CLI", "1"), ("CURSOR_AGENT", "1")]));

assert_eq!(result.agent.as_deref(), Some("cursor"));
assert_eq!(result.signals, vec!["env:CURSOR_AGENT", "env:GEMINI_CLI"]);
}

#[test]
fn supports_force_and_disable() {
let forced = detect(env(&[
Expand Down Expand Up @@ -835,5 +850,6 @@ mod tests {
assert!(format_init(Some("codex")).contains("AI_AGENT=codex"));
assert!(format_init(Some("roo")).contains("AI_AGENT=roo-code"));
assert!(format_init(None).contains("agenthint init <agent-name>"));
assert!(format_init(Some("--help")).contains("agenthint init <agent-name>"));
}
}
14 changes: 5 additions & 9 deletions crates/agenthint/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,13 @@ use agenthint::{
fn main() {
let args = std::env::args().skip(1).collect::<Vec<_>>();

if args.iter().any(|arg| arg == "-h" || arg == "--help") {
if args.len() == 1 {
print_help();
std::process::exit(0);
}

print_usage_error(&format!("invalid usage: {}", args.join(" ")));
if args.len() == 1 && (args[0] == "-h" || args[0] == "--help") {
print_help();
std::process::exit(0);
}

if let Some(init_index) = args.iter().position(|arg| arg == "init") {
if init_index != 0 || args.len() != 2 || args[1].trim().is_empty() {
if args.first().is_some_and(|arg| arg == "init") {
if args.len() != 2 || args[1].trim().is_empty() || args[1].starts_with('-') {
print_usage_error(&format_init(None));
}

Expand Down
6 changes: 5 additions & 1 deletion docs/signals.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ Detection is advisory. Signals describe why `agenthint` returned a result; they
| `env:KILOCODE_AGENT` | `kilocode` | `0.82` | heuristic |
| `env:OPENCLAW_AGENT` | `openclaw` | `0.82` | heuristic |

`CLAUDE_CODE_IS_COWORK` is only a classifier. It selects `cowork` when another Claude signal is present; it is not an agent signal by itself.
`CLAUDE_CODE_IS_COWORK` is only a classifier. It selects `cowork` when another Claude signal is present; it is not an agent signal by itself. When it selects `cowork`, `env:CLAUDE_CODE_IS_COWORK` is included in `signals` so the classification stays explainable.

`REPL_ID` is present in every Replit workspace, including human-driven sessions, which is why it reports a low `0.65` confidence. Exit-code consumers that want to avoid false positives can read `confidence` from `agenthint --json` and apply their own threshold.

## Filesystem Heuristics

Expand All @@ -69,6 +71,8 @@ Detection is advisory. Signals describe why `agenthint` returned a result; they

Parent process signals report normalized executable names only, not full paths.

Parent process detection reads `/proc` or `ps`, so it is effectively unavailable on Windows unless a parent process name is supplied explicitly through library options.

## Stdio Hints

| Signal | Agent | Confidence | Type | Notes |
Expand Down
6 changes: 6 additions & 0 deletions fixtures/cli-cases.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,11 @@
"env": {},
"status": 0,
"stdout": "AI_AGENT=roo-code\n\nUse this value in the environment used for agent tool calls.\n"
},
{
"name": "init rejects option-like names",
"args": ["init", "--json"],
"env": {},
"status": 2
}
]
26 changes: 25 additions & 1 deletion fixtures/detection-cases.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"isAgent": true,
"agent": "cowork",
"confidence": 0.9,
"signals": ["env:CLAUDE_CODE"]
"signals": ["env:CLAUDE_CODE", "env:CLAUDE_CODE_IS_COWORK"]
},
{
"name": "aider prefix",
Expand All @@ -63,6 +63,30 @@
"confidence": 0.86,
"signals": ["env:AIDER_MODEL"]
},
{
"name": "prefix signals are sorted",
"env": { "AIDER_ZZZ": "1", "AIDER_MODEL": "sonnet", "AIDER_AAA": "1" },
"isAgent": true,
"agent": "aider",
"confidence": 0.86,
"signals": ["env:AIDER_AAA", "env:AIDER_MODEL", "env:AIDER_ZZZ"]
},
{
"name": "exact and prefix matches are deduplicated",
"env": { "CURSOR_AGENT": "1", "CURSOR_TRACE_ID": "trace" },
"isAgent": true,
"agent": "cursor",
"confidence": 0.92,
"signals": ["env:CURSOR_AGENT", "env:CURSOR_TRACE_ID"]
},
{
"name": "equal confidence tie prefers the earlier rule",
"env": { "GEMINI_CLI": "1", "CURSOR_AGENT": "1" },
"isAgent": true,
"agent": "cursor",
"confidence": 0.92,
"signals": ["env:CURSOR_AGENT", "env:GEMINI_CLI"]
},
{
"name": "stronger heuristic wins over earlier weaker signal",
"env": { "REPL_ID": "repl-id", "ANTIGRAVITY_AGENT": "1" },
Expand Down
1 change: 1 addition & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ detect_asset() {
Linux)
case "$arch" in
x86_64 | amd64) echo "agenthint-linux-x64" ;;
aarch64 | arm64) echo "agenthint-linux-arm64" ;;
*) echo "agenthint install: unsupported Linux architecture: $arch" >&2; exit 1 ;;
esac
;;
Expand Down
Loading