33 * a specific AI coding agent.
44 *
55 * 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)
6+ * 1. **Environment variables** (sync) — agents inject these into child
7+ * processes. Adapted from Vercel's @vercel/detect-agent (Apache-2.0).
8+ * 2. **Process tree walking** (async) — scan parent/grandparent process
9+ * names for known agent executables. Runs as a non-blocking background
10+ * task so it never delays CLI startup.
1011 *
1112 * To add a new agent, add entries to {@link ENV_VAR_AGENTS} and/or
1213 * {@link PROCESS_NAME_AGENTS}.
1314 */
1415
15- import { execFileSync } from "node:child_process" ;
16- import { readFileSync } from "node:fs" ;
16+ import { execFile } from "node:child_process" ;
17+ import { readFile } from "node:fs/promises " ;
1718import { basename } from "node:path" ;
1819
1920import { getEnv } from "./env.js" ;
@@ -87,10 +88,10 @@ type ProcessInfo = {
8788} ;
8889
8990/**
90- * Process info provider signature. Default reads from `/proc/` or `ps(1)`.
91+ * Async process info provider signature. Default reads from `/proc/` or `ps(1)`.
9192 * Override via {@link setProcessInfoProvider} for testing.
9293 */
93- type ProcessInfoProvider = ( pid : number ) => ProcessInfo | undefined ;
94+ type ProcessInfoProvider = ( pid : number ) => Promise < ProcessInfo | undefined > ;
9495
9596let _getProcessInfo : ProcessInfoProvider = getProcessInfoFromOS ;
9697
@@ -105,16 +106,16 @@ export function setProcessInfoProvider(provider: ProcessInfoProvider): void {
105106}
106107
107108/**
108- * Detect which AI agent (if any) is invoking the CLI .
109+ * Detect agent from environment variables only (synchronous, no I/O) .
109110 *
110111 * Priority:
111112 * 1. `AI_AGENT` env var — explicit override, any agent can self-identify
112113 * 2. Agent-specific env vars from {@link ENV_VAR_AGENTS}
113114 * 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
115+ * 4. `AGENT` env var — generic fallback set by Goose, Amp, and others
116116 *
117117 * Returns the agent name string, or `undefined` if no agent is detected.
118+ * For process tree fallback, use {@link detectAgentFromProcessTree} separately.
118119 */
119120export function detectAgent ( ) : string | undefined {
120121 const env = getEnv ( ) ;
@@ -137,31 +138,28 @@ export function detectAgent(): string | undefined {
137138 return env . CLAUDE_CODE_IS_COWORK ? "cowork" : "claude" ;
138139 }
139140
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
141+ // 4. Lowest priority: generic AGENT fallback
147142 return env . AGENT ?. trim ( ) || undefined ;
148143}
149144
150145/**
151146 * Walk the ancestor process tree looking for known agent executables.
152147 *
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) .
148+ * Fully async — never blocks CLI startup. Starts at the direct parent
149+ * (`process.ppid`) and walks up to {@link MAX_ANCESTOR_DEPTH} levels.
150+ * Stops at PID 1 (init/launchd) or on any read error.
156151 *
157- * On Linux, reads `/proc/<pid>/status` (in-memory, fast).
158- * On macOS, falls back to `ps(1)`.
152+ * - **Linux**: reads `/proc/<pid>/status` (in-memory filesystem, fast).
153+ * - **macOS**: uses `ps(1)` with a 500ms timeout per invocation.
154+ * - **Windows**: not supported (env var detection still works).
159155 */
160- export function detectAgentFromProcessTree ( ) : string | undefined {
156+ export async function detectAgentFromProcessTree ( ) : Promise <
157+ string | undefined
158+ > {
161159 let pid = process . ppid ;
162160
163161 for ( let depth = 0 ; depth < MAX_ANCESTOR_DEPTH && pid > 1 ; depth ++ ) {
164- const info = _getProcessInfo ( pid ) ;
162+ const info = await _getProcessInfo ( pid ) ;
165163 if ( ! info ) {
166164 break ;
167165 }
@@ -182,13 +180,14 @@ export function detectAgentFromProcessTree(): string | undefined {
182180 *
183181 * Tries `/proc/<pid>/status` first (Linux, no subprocess overhead),
184182 * 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.
183+ * Windows is unsupported — returns `undefined`.
187184 */
188- export function getProcessInfoFromOS ( pid : number ) : ProcessInfo | undefined {
189- // Linux: /proc is an in-memory filesystem — no subprocess needed
185+ export async function getProcessInfoFromOS (
186+ pid : number
187+ ) : Promise < ProcessInfo | undefined > {
188+ // Linux: /proc is an in-memory filesystem — fast even though async
190189 try {
191- const status = readFileSync ( `/proc/${ pid } /status` , "utf-8" ) ;
190+ const status = await readFile ( `/proc/${ pid } /status` , "utf-8" ) ;
192191 const nameMatch = status . match ( PROC_STATUS_NAME_RE ) ;
193192 const ppidMatch = status . match ( PROC_STATUS_PPID_RE ) ;
194193 if ( nameMatch ?. [ 1 ] && ppidMatch ?. [ 1 ] ) {
@@ -198,27 +197,37 @@ export function getProcessInfoFromOS(pid: number): ProcessInfo | undefined {
198197 // Not Linux or process is gone — fall through to ps
199198 }
200199
201- // macOS / other Unix: use ps(1)
200+ // macOS / other Unix: use ps(1) asynchronously
202201 if ( process . platform !== "win32" ) {
203202 try {
204- const result = execFileSync (
203+ const result = await execFilePromise (
205204 "ps" ,
206205 [ "-p" , String ( pid ) , "-o" , "ppid=,comm=" ] ,
207- {
208- encoding : "utf-8" ,
209- stdio : [ "pipe" , "pipe" , "ignore" ] ,
210- // Guard against ps hanging on degraded systems — this runs
211- // synchronously during CLI startup, so keep it tight
212- timeout : 500 ,
213- }
206+ { timeout : 500 }
214207 ) ;
215- // Output: " 1234 /Applications/Cursor.app/Contents/MacOS/Cursor"
216208 const match = result . trim ( ) . match ( PS_PPID_COMM_RE ) ;
217209 if ( match ?. [ 1 ] && match ?. [ 2 ] ) {
218210 return { name : basename ( match [ 2 ] . trim ( ) ) , ppid : Number ( match [ 1 ] ) } ;
219211 }
220212 } catch {
221- // Process gone or ps not available
213+ // Process gone, ps not available, or timeout
222214 }
223215 }
224216}
217+
218+ /** Promisified `execFile` — resolves with stdout, rejects on error/timeout. */
219+ function execFilePromise (
220+ cmd : string ,
221+ args : readonly string [ ] ,
222+ opts : { timeout ?: number }
223+ ) : Promise < string > {
224+ return new Promise ( ( resolve , reject ) => {
225+ execFile ( cmd , args , { encoding : "utf-8" , ...opts } , ( err , stdout ) => {
226+ if ( err ) {
227+ reject ( err ) ;
228+ } else {
229+ resolve ( stdout ) ;
230+ }
231+ } ) ;
232+ } ) ;
233+ }
0 commit comments