|
2 | 2 | * AI agent detection — determines whether the CLI is being driven by |
3 | 3 | * a specific AI coding agent. |
4 | 4 | * |
5 | | - * Detection is based on environment variables that agents inject into |
6 | | - * child processes. Adapted from Vercel's @vercel/detect-agent (Apache-2.0). |
| 5 | + * Detection uses two strategies: |
| 6 | + * 1. **Environment variables** that agents inject into child processes |
| 7 | + * (adapted from Vercel's @vercel/detect-agent, Apache-2.0) |
| 8 | + * 2. **Process tree walking** — scan parent/grandparent process names |
| 9 | + * for known agent executables (fallback when env vars are absent) |
7 | 10 | * |
8 | | - * To add a new agent, append an entry to {@link AGENT_ENV_VARS}. |
| 11 | + * To add a new agent, add entries to {@link ENV_VAR_AGENTS} and/or |
| 12 | + * {@link PROCESS_NAME_AGENTS}. |
9 | 13 | */ |
10 | 14 |
|
| 15 | +import { execFileSync } from "node:child_process"; |
| 16 | +import { readFileSync } from "node:fs"; |
| 17 | +import { basename } from "node:path"; |
| 18 | + |
11 | 19 | import { getEnv } from "./env.js"; |
12 | 20 |
|
13 | 21 | /** |
14 | | - * Agent detection table. Checked in order — first match wins. |
15 | | - * Each entry maps one or more env vars to an agent name. |
| 22 | + * Env var → agent name. Checked in insertion order — first match wins. |
| 23 | + * Each env var maps directly to the agent that sets it. |
| 24 | + */ |
| 25 | +export const ENV_VAR_AGENTS = new Map<string, string>([ |
| 26 | + // Cursor |
| 27 | + ["CURSOR_TRACE_ID", "cursor"], |
| 28 | + ["CURSOR_AGENT", "cursor"], |
| 29 | + // Gemini CLI |
| 30 | + ["GEMINI_CLI", "gemini"], |
| 31 | + // OpenAI Codex |
| 32 | + ["CODEX_SANDBOX", "codex"], |
| 33 | + ["CODEX_CI", "codex"], |
| 34 | + ["CODEX_THREAD_ID", "codex"], |
| 35 | + // Antigravity |
| 36 | + ["ANTIGRAVITY_AGENT", "antigravity"], |
| 37 | + // Augment |
| 38 | + ["AUGMENT_AGENT", "augment"], |
| 39 | + // OpenCode |
| 40 | + ["OPENCODE_CLIENT", "opencode"], |
| 41 | + // Replit |
| 42 | + ["REPL_ID", "replit"], |
| 43 | + // GitHub Copilot |
| 44 | + ["COPILOT_MODEL", "github-copilot"], |
| 45 | + ["COPILOT_ALLOW_ALL", "github-copilot"], |
| 46 | + ["COPILOT_GITHUB_TOKEN", "github-copilot"], |
| 47 | + // Goose |
| 48 | + ["GOOSE_TERMINAL", "goose"], |
| 49 | + // Amp |
| 50 | + ["AMP_THREAD_ID", "amp"], |
| 51 | +]); |
| 52 | + |
| 53 | +/** |
| 54 | + * Process executable basename (lowercase) → agent name. |
| 55 | + * Used when scanning the parent process tree as a fallback. |
16 | 56 | */ |
17 | | -const AGENT_ENV_VARS: ReadonlyArray<{ |
18 | | - envVars: readonly string[]; |
19 | | - agent: string; |
20 | | -}> = [ |
21 | | - { envVars: ["CURSOR_TRACE_ID", "CURSOR_AGENT"], agent: "cursor" }, |
22 | | - { envVars: ["GEMINI_CLI"], agent: "gemini" }, |
23 | | - { envVars: ["CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"], agent: "codex" }, |
24 | | - { envVars: ["ANTIGRAVITY_AGENT"], agent: "antigravity" }, |
25 | | - { envVars: ["AUGMENT_AGENT"], agent: "augment" }, |
26 | | - { envVars: ["OPENCODE_CLIENT"], agent: "opencode" }, |
27 | | - { envVars: ["REPL_ID"], agent: "replit" }, |
28 | | - { |
29 | | - envVars: ["COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"], |
30 | | - agent: "github-copilot", |
31 | | - }, |
32 | | - { envVars: ["GOOSE_TERMINAL"], agent: "goose" }, |
33 | | - { envVars: ["AMP_THREAD_ID"], agent: "amp" }, |
34 | | -]; |
| 57 | +export const PROCESS_NAME_AGENTS = new Map<string, string>([ |
| 58 | + ["cursor", "cursor"], |
| 59 | + ["claude", "claude"], |
| 60 | + ["goose", "goose"], |
| 61 | + ["windsurf", "windsurf"], |
| 62 | + ["amp", "amp"], |
| 63 | + ["codex", "codex"], |
| 64 | + ["augment", "augment"], |
| 65 | + ["opencode", "opencode"], |
| 66 | + ["gemini", "gemini"], |
| 67 | +]); |
| 68 | + |
| 69 | +/** Max levels to walk up the process tree before giving up. */ |
| 70 | +const MAX_ANCESTOR_DEPTH = 5; |
| 71 | + |
| 72 | +/** Pattern to extract `Name:` from `/proc/<pid>/status`. */ |
| 73 | +const PROC_STATUS_NAME_RE = /^Name:\s+(.+)$/m; |
| 74 | + |
| 75 | +/** Pattern to extract `PPid:` from `/proc/<pid>/status`. */ |
| 76 | +const PROC_STATUS_PPID_RE = /^PPid:\s+(\d+)$/m; |
| 77 | + |
| 78 | +/** Pattern to parse `ps -o ppid=,comm=` output: " <ppid> <comm>". */ |
| 79 | +const PS_PPID_COMM_RE = /^(\d+)\s+(.+)$/; |
| 80 | + |
| 81 | +/** Name + parent PID of a process. */ |
| 82 | +type ProcessInfo = { |
| 83 | + /** Basename of the executable (e.g. "cursor", "bash"). */ |
| 84 | + name: string; |
| 85 | + /** Parent process ID, or 0 if unavailable. */ |
| 86 | + ppid: number; |
| 87 | +}; |
| 88 | + |
| 89 | +/** |
| 90 | + * Process info provider signature. Default reads from `/proc/` or `ps(1)`. |
| 91 | + * Override via {@link setProcessInfoProvider} for testing. |
| 92 | + */ |
| 93 | +type ProcessInfoProvider = (pid: number) => ProcessInfo | undefined; |
| 94 | + |
| 95 | +let _getProcessInfo: ProcessInfoProvider = getProcessInfoFromOS; |
| 96 | + |
| 97 | +/** |
| 98 | + * Override the process info provider. Follows the same pattern as |
| 99 | + * {@link setEnv} — call with a mock in tests, reset in `afterEach`. |
| 100 | + * |
| 101 | + * Pass `getProcessInfoFromOS` to restore the real implementation. |
| 102 | + */ |
| 103 | +export function setProcessInfoProvider(provider: ProcessInfoProvider): void { |
| 104 | + _getProcessInfo = provider; |
| 105 | +} |
35 | 106 |
|
36 | 107 | /** |
37 | 108 | * Detect which AI agent (if any) is invoking the CLI. |
38 | 109 | * |
39 | | - * Priority: `AI_AGENT` override > specific agent env vars > |
40 | | - * Claude Code (with cowork variant) > `AGENT` generic fallback. |
| 110 | + * Priority: |
| 111 | + * 1. `AI_AGENT` env var — explicit override, any agent can self-identify |
| 112 | + * 2. Agent-specific env vars from {@link ENV_VAR_AGENTS} |
| 113 | + * 3. Claude Code with Cowork variant (conditional, can't be in the map) |
| 114 | + * 4. Parent process tree — walk ancestors looking for known executables |
| 115 | + * 5. `AGENT` env var — generic fallback set by Goose, Amp, and others |
41 | 116 | * |
42 | 117 | * Returns the agent name string, or `undefined` if no agent is detected. |
43 | 118 | */ |
44 | 119 | export function detectAgent(): string | undefined { |
45 | 120 | const env = getEnv(); |
46 | 121 |
|
47 | | - // Highest priority: generic override — any agent can self-identify |
| 122 | + // 1. Highest priority: explicit override — any agent can self-identify |
48 | 123 | const aiAgent = env.AI_AGENT?.trim(); |
49 | 124 | if (aiAgent) { |
50 | 125 | return aiAgent; |
51 | 126 | } |
52 | 127 |
|
53 | | - // Table-driven check for known agents |
54 | | - for (const { envVars, agent } of AGENT_ENV_VARS) { |
55 | | - if (envVars.some((v) => env[v])) { |
| 128 | + // 2. Table-driven env var check (Map iteration preserves insertion order) |
| 129 | + for (const [envVar, agent] of ENV_VAR_AGENTS) { |
| 130 | + if (env[envVar]) { |
56 | 131 | return agent; |
57 | 132 | } |
58 | 133 | } |
59 | 134 |
|
60 | | - // Claude Code / Cowork (needs branching logic, so not in the table) |
| 135 | + // 3. Claude Code / Cowork — requires branching logic, so not in the map |
61 | 136 | if (env.CLAUDECODE || env.CLAUDE_CODE) { |
62 | 137 | return env.CLAUDE_CODE_IS_COWORK ? "cowork" : "claude"; |
63 | 138 | } |
64 | 139 |
|
65 | | - // Lowest priority: generic AGENT fallback (set by Goose, Amp, and others) |
| 140 | + // 4. Process tree: walk parent → grandparent → ... looking for known agents |
| 141 | + const processAgent = detectAgentFromProcessTree(); |
| 142 | + if (processAgent) { |
| 143 | + return processAgent; |
| 144 | + } |
| 145 | + |
| 146 | + // 5. Lowest priority: generic AGENT fallback |
66 | 147 | return env.AGENT?.trim() || undefined; |
67 | 148 | } |
| 149 | + |
| 150 | +/** |
| 151 | + * Walk the ancestor process tree looking for known agent executables. |
| 152 | + * |
| 153 | + * Starts at the direct parent (`process.ppid`) and walks up to |
| 154 | + * {@link MAX_ANCESTOR_DEPTH} levels. Stops at PID 1 (init/launchd) |
| 155 | + * or on any read error (process exited, permission denied). |
| 156 | + * |
| 157 | + * On Linux, reads `/proc/<pid>/status` (in-memory, fast). |
| 158 | + * On macOS, falls back to `ps(1)`. |
| 159 | + */ |
| 160 | +export function detectAgentFromProcessTree(): string | undefined { |
| 161 | + let pid = process.ppid; |
| 162 | + |
| 163 | + for (let depth = 0; depth < MAX_ANCESTOR_DEPTH && pid > 1; depth++) { |
| 164 | + const info = _getProcessInfo(pid); |
| 165 | + if (!info) { |
| 166 | + break; |
| 167 | + } |
| 168 | + |
| 169 | + const agent = PROCESS_NAME_AGENTS.get(info.name.toLowerCase()); |
| 170 | + if (agent) { |
| 171 | + return agent; |
| 172 | + } |
| 173 | + |
| 174 | + pid = info.ppid; |
| 175 | + } |
| 176 | + |
| 177 | + return; |
| 178 | +} |
| 179 | + |
| 180 | +/** |
| 181 | + * Read process name and parent PID for a given PID. |
| 182 | + * |
| 183 | + * Tries `/proc/<pid>/status` first (Linux, no subprocess overhead), |
| 184 | + * falls back to `ps(1)` (macOS and other Unix systems). |
| 185 | + * |
| 186 | + * Returns `undefined` if the process doesn't exist or can't be read. |
| 187 | + */ |
| 188 | +export function getProcessInfoFromOS(pid: number): ProcessInfo | undefined { |
| 189 | + // Linux: /proc is an in-memory filesystem — no subprocess needed |
| 190 | + try { |
| 191 | + const status = readFileSync(`/proc/${pid}/status`, "utf-8"); |
| 192 | + const nameMatch = status.match(PROC_STATUS_NAME_RE); |
| 193 | + const ppidMatch = status.match(PROC_STATUS_PPID_RE); |
| 194 | + if (nameMatch?.[1] && ppidMatch?.[1]) { |
| 195 | + return { name: nameMatch[1].trim(), ppid: Number(ppidMatch[1]) }; |
| 196 | + } |
| 197 | + } catch { |
| 198 | + // Not Linux or process is gone — fall through to ps |
| 199 | + } |
| 200 | + |
| 201 | + // macOS / other Unix: use ps(1) |
| 202 | + if (process.platform !== "win32") { |
| 203 | + try { |
| 204 | + const result = execFileSync( |
| 205 | + "ps", |
| 206 | + ["-p", String(pid), "-o", "ppid=,comm="], |
| 207 | + { |
| 208 | + encoding: "utf-8", |
| 209 | + stdio: ["pipe", "pipe", "ignore"], |
| 210 | + } |
| 211 | + ); |
| 212 | + // Output: " 1234 /Applications/Cursor.app/Contents/MacOS/Cursor" |
| 213 | + const match = result.trim().match(PS_PPID_COMM_RE); |
| 214 | + if (match?.[1] && match?.[2]) { |
| 215 | + return { name: basename(match[2].trim()), ppid: Number(match[1]) }; |
| 216 | + } |
| 217 | + } catch { |
| 218 | + // Process gone or ps not available |
| 219 | + } |
| 220 | + } |
| 221 | +} |
0 commit comments