Skip to content

Commit 8f161b5

Browse files
committed
refactor(telemetry): make process tree detection async
Process tree detection (reading /proc/ or spawning ps) should never block CLI startup for a telemetry side-effect. Split the detection: - detectAgent() is sync — env var lookups only (instant) - detectAgentFromProcessTree() is async — fires as a background task in initSentry(), sets the Sentry tag when it resolves Uses readFile (node:fs/promises) and promisified execFile instead of their sync counterparts. Windows is explicitly unsupported for process tree detection (env var detection still works everywhere).
1 parent 753fd87 commit 8f161b5

File tree

3 files changed

+98
-125
lines changed

3 files changed

+98
-125
lines changed

src/lib/detect-agent.ts

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,18 @@
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";
1718
import { basename } from "node:path";
1819

1920
import { 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

9596
let _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
*/
119120
export 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+
}

src/lib/telemetry.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
SENTRY_CLI_DSN,
2020
} from "./constants.js";
2121
import { isReadonlyError, tryRepairAndRetry } from "./db/schema.js";
22-
import { detectAgent } from "./detect-agent.js";
22+
import { detectAgent, detectAgentFromProcessTree } from "./detect-agent.js";
2323
import { getEnv } from "./env.js";
2424
import { ApiError, AuthError, OutputError } from "./errors.js";
2525
import { attachSentryReporter } from "./logger.js";
@@ -523,10 +523,19 @@ export function initSentry(
523523
// Tag whether running in an interactive terminal or agent/CI environment
524524
Sentry.setTag("is_tty", !!process.stdout.isTTY);
525525

526-
// Tag which AI agent (if any) is driving the CLI
526+
// Tag which AI agent (if any) is driving the CLI.
527+
// Env var detection is sync (instant). If no env var matches, fire off
528+
// async process tree detection in the background — it sets the tag
529+
// before the transaction finishes without blocking CLI startup.
527530
const agent = detectAgent();
528531
if (agent) {
529532
Sentry.setTag("agent", agent);
533+
} else {
534+
detectAgentFromProcessTree().then((processAgent) => {
535+
if (processAgent) {
536+
Sentry.setTag("agent", processAgent);
537+
}
538+
});
530539
}
531540

532541
// Wire up consola → Sentry log forwarding now that the client is active

0 commit comments

Comments
 (0)