From eff0160610140f9cc885f14f2aa258cf34285d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Wed, 8 Apr 2026 13:16:06 +0200 Subject: [PATCH 1/9] feat(telemetry): add agent detection tag for AI coding tools Detect whether the CLI is being driven by an AI coding agent and tag the telemetry span with the agent name. Uses environment variables that agents inject into child processes (e.g. CLAUDE_CODE, CURSOR_TRACE_ID). Supports a generic AI_AGENT override and an AGENT fallback so new tools work out of the box. Adapted from Vercel's @vercel/detect-agent. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/detect-agent.ts | 67 ++++++++++++ src/lib/telemetry.ts | 7 ++ test/lib/detect-agent.test.ts | 186 ++++++++++++++++++++++++++++++++++ 3 files changed, 260 insertions(+) create mode 100644 src/lib/detect-agent.ts create mode 100644 test/lib/detect-agent.test.ts diff --git a/src/lib/detect-agent.ts b/src/lib/detect-agent.ts new file mode 100644 index 000000000..7208e4031 --- /dev/null +++ b/src/lib/detect-agent.ts @@ -0,0 +1,67 @@ +/** + * AI agent detection — determines whether the CLI is being driven by + * a specific AI coding agent. + * + * Detection is based on environment variables that agents inject into + * child processes. Adapted from Vercel's @vercel/detect-agent (Apache-2.0). + * + * To add a new agent, append an entry to {@link AGENT_ENV_VARS}. + */ + +import { getEnv } from "./env.js"; + +/** + * Agent detection table. Checked in order — first match wins. + * Each entry maps one or more env vars to an agent name. + */ +const AGENT_ENV_VARS: ReadonlyArray<{ + envVars: readonly string[]; + agent: string; +}> = [ + { envVars: ["CURSOR_TRACE_ID", "CURSOR_AGENT"], agent: "cursor" }, + { envVars: ["GEMINI_CLI"], agent: "gemini" }, + { envVars: ["CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"], agent: "codex" }, + { envVars: ["ANTIGRAVITY_AGENT"], agent: "antigravity" }, + { envVars: ["AUGMENT_AGENT"], agent: "augment" }, + { envVars: ["OPENCODE_CLIENT"], agent: "opencode" }, + { envVars: ["REPL_ID"], agent: "replit" }, + { + envVars: ["COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"], + agent: "github-copilot", + }, + { envVars: ["GOOSE_TERMINAL"], agent: "goose" }, + { envVars: ["AMP_THREAD_ID"], agent: "amp" }, +]; + +/** + * Detect which AI agent (if any) is invoking the CLI. + * + * Priority: `AI_AGENT` override > specific agent env vars > + * Claude Code (with cowork variant) > `AGENT` generic fallback. + * + * Returns the agent name string, or `undefined` if no agent is detected. + */ +export function detectAgent(): string | undefined { + const env = getEnv(); + + // Highest priority: generic override — any agent can self-identify + const aiAgent = env.AI_AGENT?.trim(); + if (aiAgent) { + return aiAgent; + } + + // Table-driven check for known agents + for (const { envVars, agent } of AGENT_ENV_VARS) { + if (envVars.some((v) => env[v])) { + return agent; + } + } + + // Claude Code / Cowork (needs branching logic, so not in the table) + if (env.CLAUDECODE || env.CLAUDE_CODE) { + return env.CLAUDE_CODE_IS_COWORK ? "cowork" : "claude"; + } + + // Lowest priority: generic AGENT fallback (set by Goose, Amp, and others) + return env.AGENT?.trim() || undefined; +} diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index e90238f04..6c7f1c61e 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -19,6 +19,7 @@ import { SENTRY_CLI_DSN, } from "./constants.js"; import { isReadonlyError, tryRepairAndRetry } from "./db/schema.js"; +import { detectAgent } from "./detect-agent.js"; import { getEnv } from "./env.js"; import { ApiError, AuthError, OutputError } from "./errors.js"; import { attachSentryReporter } from "./logger.js"; @@ -522,6 +523,12 @@ export function initSentry( // Tag whether running in an interactive terminal or agent/CI environment Sentry.setTag("is_tty", !!process.stdout.isTTY); + // Tag which AI agent (if any) is driving the CLI + const agent = detectAgent(); + if (agent) { + Sentry.setTag("agent", agent); + } + // Wire up consola → Sentry log forwarding now that the client is active attachSentryReporter(); diff --git a/test/lib/detect-agent.test.ts b/test/lib/detect-agent.test.ts new file mode 100644 index 000000000..d9a9781e6 --- /dev/null +++ b/test/lib/detect-agent.test.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { detectAgent } from "../../src/lib/detect-agent.js"; +import { setEnv } from "../../src/lib/env.js"; + +function withEnv(vars: Record) { + setEnv(vars as NodeJS.ProcessEnv); +} + +describe("detectAgent", () => { + afterEach(() => { + setEnv(process.env); + }); + + // ── AI_AGENT override ────────────────────────────────────────────── + + test("AI_AGENT takes highest priority", () => { + withEnv({ AI_AGENT: "custom-agent", CLAUDE_CODE: "1", CI: "true" }); + expect(detectAgent()).toBe("custom-agent"); + }); + + test("AI_AGENT empty string is ignored", () => { + withEnv({ AI_AGENT: "" }); + expect(detectAgent()).toBeUndefined(); + }); + + test("AI_AGENT whitespace-only is ignored", () => { + withEnv({ AI_AGENT: " " }); + expect(detectAgent()).toBeUndefined(); + }); + + test("AI_AGENT is trimmed", () => { + withEnv({ AI_AGENT: " my-agent " }); + expect(detectAgent()).toBe("my-agent"); + }); + + // ── Cursor ───────────────────────────────────────────────────────── + + test("CURSOR_TRACE_ID → cursor", () => { + withEnv({ CURSOR_TRACE_ID: "abc123" }); + expect(detectAgent()).toBe("cursor"); + }); + + test("CURSOR_AGENT → cursor", () => { + withEnv({ CURSOR_AGENT: "1" }); + expect(detectAgent()).toBe("cursor"); + }); + + test("CURSOR_TRACE_ID takes priority over CURSOR_AGENT", () => { + withEnv({ CURSOR_TRACE_ID: "abc", CURSOR_AGENT: "1" }); + expect(detectAgent()).toBe("cursor"); + }); + + // ── Gemini ───────────────────────────────────────────────────────── + + test("GEMINI_CLI → gemini", () => { + withEnv({ GEMINI_CLI: "1" }); + expect(detectAgent()).toBe("gemini"); + }); + + // ── Codex ────────────────────────────────────────────────────────── + + test("CODEX_SANDBOX → codex", () => { + withEnv({ CODEX_SANDBOX: "1" }); + expect(detectAgent()).toBe("codex"); + }); + + test("CODEX_CI → codex", () => { + withEnv({ CODEX_CI: "1" }); + expect(detectAgent()).toBe("codex"); + }); + + test("CODEX_THREAD_ID → codex", () => { + withEnv({ CODEX_THREAD_ID: "thread-123" }); + expect(detectAgent()).toBe("codex"); + }); + + // ── Antigravity ──────────────────────────────────────────────────── + + test("ANTIGRAVITY_AGENT → antigravity", () => { + withEnv({ ANTIGRAVITY_AGENT: "1" }); + expect(detectAgent()).toBe("antigravity"); + }); + + // ── Augment ──────────────────────────────────────────────────────── + + test("AUGMENT_AGENT → augment", () => { + withEnv({ AUGMENT_AGENT: "1" }); + expect(detectAgent()).toBe("augment"); + }); + + // ── OpenCode ─────────────────────────────────────────────────────── + + test("OPENCODE_CLIENT → opencode", () => { + withEnv({ OPENCODE_CLIENT: "1" }); + expect(detectAgent()).toBe("opencode"); + }); + + // ── Claude Code ──────────────────────────────────────────────────── + + test("CLAUDE_CODE → claude", () => { + withEnv({ CLAUDE_CODE: "1" }); + expect(detectAgent()).toBe("claude"); + }); + + test("CLAUDECODE → claude", () => { + withEnv({ CLAUDECODE: "1" }); + expect(detectAgent()).toBe("claude"); + }); + + test("CLAUDE_CODE + CLAUDE_CODE_IS_COWORK → cowork", () => { + withEnv({ CLAUDE_CODE: "1", CLAUDE_CODE_IS_COWORK: "1" }); + expect(detectAgent()).toBe("cowork"); + }); + + test("CLAUDECODE + CLAUDE_CODE_IS_COWORK → cowork", () => { + withEnv({ CLAUDECODE: "1", CLAUDE_CODE_IS_COWORK: "1" }); + expect(detectAgent()).toBe("cowork"); + }); + + // ── Replit ───────────────────────────────────────────────────────── + + test("REPL_ID → replit", () => { + withEnv({ REPL_ID: "abc123" }); + expect(detectAgent()).toBe("replit"); + }); + + // ── GitHub Copilot ───────────────────────────────────────────────── + + test("COPILOT_MODEL → github-copilot", () => { + withEnv({ COPILOT_MODEL: "gpt-4" }); + expect(detectAgent()).toBe("github-copilot"); + }); + + test("COPILOT_ALLOW_ALL → github-copilot", () => { + withEnv({ COPILOT_ALLOW_ALL: "1" }); + expect(detectAgent()).toBe("github-copilot"); + }); + + test("COPILOT_GITHUB_TOKEN → github-copilot", () => { + withEnv({ COPILOT_GITHUB_TOKEN: "ghu_xxx" }); + expect(detectAgent()).toBe("github-copilot"); + }); + + // ── Goose ────────────────────────────────────────────────────────── + + test("GOOSE_TERMINAL → goose", () => { + withEnv({ GOOSE_TERMINAL: "1" }); + expect(detectAgent()).toBe("goose"); + }); + + // ── Amp ──────────────────────────────────────────────────────────── + + test("AMP_THREAD_ID → amp", () => { + withEnv({ AMP_THREAD_ID: "thread-456" }); + expect(detectAgent()).toBe("amp"); + }); + + // ── AGENT generic fallback ───────────────────────────────────────── + + test("AGENT as generic fallback", () => { + withEnv({ AGENT: "some-new-agent" }); + expect(detectAgent()).toBe("some-new-agent"); + }); + + test("AGENT is trimmed", () => { + withEnv({ AGENT: " goose " }); + expect(detectAgent()).toBe("goose"); + }); + + test("AGENT empty string is ignored", () => { + withEnv({ AGENT: "" }); + expect(detectAgent()).toBeUndefined(); + }); + + test("specific env vars take priority over AGENT", () => { + withEnv({ AGENT: "goose", CLAUDE_CODE: "1" }); + expect(detectAgent()).toBe("claude"); + }); + + // ── No agent ─────────────────────────────────────────────────────── + + test("no env vars → undefined", () => { + withEnv({}); + expect(detectAgent()).toBeUndefined(); + }); +}); From fa2b8758df5a07c70a046de43c13a6e69c2fb1df Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Apr 2026 11:16:50 +0000 Subject: [PATCH 2/9] chore: regenerate skill files and command docs --- plugins/sentry-cli/skills/sentry-cli/references/dashboard.md | 3 +-- plugins/sentry-cli/skills/sentry-cli/references/event.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/issue.md | 4 ++-- plugins/sentry-cli/skills/sentry-cli/references/log.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/span.md | 2 +- plugins/sentry-cli/skills/sentry-cli/references/trace.md | 4 ++-- 6 files changed, 8 insertions(+), 9 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index 2cfc11b27..2469f6584 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -42,7 +42,7 @@ View a dashboard - `-w, --web - Open in browser` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08"` **Examples:** @@ -86,7 +86,6 @@ Add a widget to a dashboard - `--y - Grid row position (0-based)` - `--width - Widget width in grid columns (1–6)` - `--height - Widget height in grid rows (min 1)` -- `-l, --layout - Layout mode: sequential (append in order) or dense (fill gaps) - (default: "sequential")` **Examples:** diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md index aac2d9a45..85f9a8c54 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -28,7 +28,7 @@ List events for an issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index effc7eb06..a335718bc 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -19,7 +19,7 @@ List issues in a project - `-q, --query - Search query (Sentry search syntax)` - `-n, --limit - Maximum number of issues to list - (default: "25")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "90d")` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "90d")` - `-c, --cursor - Pagination cursor (use "next" for next page, "prev" for previous)` - `--compact - Single-line rows for compact output (auto-detects if omitted)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` @@ -78,7 +78,7 @@ List events for a specific issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/log.md b/plugins/sentry-cli/skills/sentry-cli/references/log.md index 65ed8315d..dd2604b52 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/log.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/log.md @@ -19,7 +19,7 @@ List logs from a project - `-n, --limit - Number of log entries (1-1000) - (default: "100")` - `-q, --query - Filter query (Sentry search syntax)` - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08"` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` - `--fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/span.md b/plugins/sentry-cli/skills/sentry-cli/references/span.md index 4ac04ece9..d7892ad94 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/span.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/span.md @@ -19,7 +19,7 @@ List spans in a project or trace - `-n, --limit - Number of spans (<=1000) - (default: "25")` - `-q, --query - Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")` - `-s, --sort - Sort order: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/plugins/sentry-cli/skills/sentry-cli/references/trace.md index a0cae18cc..1fb9244f2 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/trace.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/trace.md @@ -19,7 +19,7 @@ List recent traces in a project - `-n, --limit - Number of traces (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `-s, --sort - Sort by: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` @@ -78,7 +78,7 @@ View logs associated with a trace **Flags:** - `-w, --web - Open trace in browser` -- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "14d")` +- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "14d")` - `-n, --limit - Number of log entries (<=1000) - (default: "100")` - `-q, --query - Additional filter query (Sentry search syntax)` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` From 15f3696d1810d7054836103bf7e56c6f44a2944b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 9 Apr 2026 15:42:24 +0000 Subject: [PATCH 3/9] refactor(telemetry): use Map for agent env vars, add process tree detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert AGENT_ENV_VARS array-of-objects to a flat Map mapping each env var directly to its agent name (simpler lookups, one-line additions for new agents) - Add process tree walking as fallback detection: scan parent → grandparent → ... for known agent executables (up to 5 levels) - Linux: reads /proc//status (in-memory, no subprocess overhead) - macOS: falls back to ps(1) via execFileSync - Injectable via setProcessInfoProvider() for testing (same pattern as setEnv) - 50 tests (up from 29) covering env vars, Map structure, process tree walking, depth limits, and real process info reads --- .../skills/sentry-cli/references/dashboard.md | 3 +- .../skills/sentry-cli/references/event.md | 2 +- .../skills/sentry-cli/references/issue.md | 4 +- .../skills/sentry-cli/references/log.md | 2 +- .../skills/sentry-cli/references/span.md | 2 +- .../skills/sentry-cli/references/trace.md | 4 +- src/lib/detect-agent.ts | 216 +++++++++++++++--- test/lib/detect-agent.test.ts | 215 ++++++++++++++++- 8 files changed, 407 insertions(+), 41 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index 2469f6584..2cfc11b27 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -42,7 +42,7 @@ View a dashboard - `-w, --web - Open in browser` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` -- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08"` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"` **Examples:** @@ -86,6 +86,7 @@ Add a widget to a dashboard - `--y - Grid row position (0-based)` - `--width - Widget width in grid columns (1–6)` - `--height - Widget height in grid rows (min 1)` +- `-l, --layout - Layout mode: sequential (append in order) or dense (fill gaps) - (default: "sequential")` **Examples:** diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md index 85f9a8c54..aac2d9a45 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -28,7 +28,7 @@ List events for an issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index a335718bc..effc7eb06 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -19,7 +19,7 @@ List issues in a project - `-q, --query - Search query (Sentry search syntax)` - `-n, --limit - Maximum number of issues to list - (default: "25")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "90d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "90d")` - `-c, --cursor - Pagination cursor (use "next" for next page, "prev" for previous)` - `--compact - Single-line rows for compact output (auto-detects if omitted)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` @@ -78,7 +78,7 @@ List events for a specific issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/log.md b/plugins/sentry-cli/skills/sentry-cli/references/log.md index dd2604b52..65ed8315d 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/log.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/log.md @@ -19,7 +19,7 @@ List logs from a project - `-n, --limit - Number of log entries (1-1000) - (default: "100")` - `-q, --query - Filter query (Sentry search syntax)` - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` -- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08"` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01"` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` - `--fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/span.md b/plugins/sentry-cli/skills/sentry-cli/references/span.md index d7892ad94..4ac04ece9 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/span.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/span.md @@ -19,7 +19,7 @@ List spans in a project or trace - `-n, --limit - Number of spans (<=1000) - (default: "25")` - `-q, --query - Filter spans (e.g., "op:db", "duration:>100ms", "project:backend")` - `-s, --sort - Sort order: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/plugins/sentry-cli/skills/sentry-cli/references/trace.md index 1fb9244f2..a0cae18cc 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/trace.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/trace.md @@ -19,7 +19,7 @@ List recent traces in a project - `-n, --limit - Number of traces (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `-s, --sort - Sort by: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` @@ -78,7 +78,7 @@ View logs associated with a trace **Flags:** - `-w, --web - Open trace in browser` -- `-t, --period - Time range: "7d", "2026-03-08..2026-04-08", ">=2026-03-08" - (default: "14d")` +- `-t, --period - Time range: "7d", "2026-03-01..2026-04-01", ">=2026-03-01" - (default: "14d")` - `-n, --limit - Number of log entries (<=1000) - (default: "100")` - `-q, --query - Additional filter query (Sentry search syntax)` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` diff --git a/src/lib/detect-agent.ts b/src/lib/detect-agent.ts index 7208e4031..e2463ba6e 100644 --- a/src/lib/detect-agent.ts +++ b/src/lib/detect-agent.ts @@ -2,66 +2,220 @@ * AI agent detection — determines whether the CLI is being driven by * a specific AI coding agent. * - * Detection is based on environment variables that agents inject into - * child processes. Adapted from Vercel's @vercel/detect-agent (Apache-2.0). + * Detection uses two strategies: + * 1. **Environment variables** that agents inject into child processes + * (adapted from Vercel's @vercel/detect-agent, Apache-2.0) + * 2. **Process tree walking** — scan parent/grandparent process names + * for known agent executables (fallback when env vars are absent) * - * To add a new agent, append an entry to {@link AGENT_ENV_VARS}. + * To add a new agent, add entries to {@link ENV_VAR_AGENTS} and/or + * {@link PROCESS_NAME_AGENTS}. */ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; + import { getEnv } from "./env.js"; /** - * Agent detection table. Checked in order — first match wins. - * Each entry maps one or more env vars to an agent name. + * Env var → agent name. Checked in insertion order — first match wins. + * Each env var maps directly to the agent that sets it. + */ +export const ENV_VAR_AGENTS = new Map([ + // Cursor + ["CURSOR_TRACE_ID", "cursor"], + ["CURSOR_AGENT", "cursor"], + // Gemini CLI + ["GEMINI_CLI", "gemini"], + // OpenAI Codex + ["CODEX_SANDBOX", "codex"], + ["CODEX_CI", "codex"], + ["CODEX_THREAD_ID", "codex"], + // Antigravity + ["ANTIGRAVITY_AGENT", "antigravity"], + // Augment + ["AUGMENT_AGENT", "augment"], + // OpenCode + ["OPENCODE_CLIENT", "opencode"], + // Replit + ["REPL_ID", "replit"], + // GitHub Copilot + ["COPILOT_MODEL", "github-copilot"], + ["COPILOT_ALLOW_ALL", "github-copilot"], + ["COPILOT_GITHUB_TOKEN", "github-copilot"], + // Goose + ["GOOSE_TERMINAL", "goose"], + // Amp + ["AMP_THREAD_ID", "amp"], +]); + +/** + * Process executable basename (lowercase) → agent name. + * Used when scanning the parent process tree as a fallback. */ -const AGENT_ENV_VARS: ReadonlyArray<{ - envVars: readonly string[]; - agent: string; -}> = [ - { envVars: ["CURSOR_TRACE_ID", "CURSOR_AGENT"], agent: "cursor" }, - { envVars: ["GEMINI_CLI"], agent: "gemini" }, - { envVars: ["CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"], agent: "codex" }, - { envVars: ["ANTIGRAVITY_AGENT"], agent: "antigravity" }, - { envVars: ["AUGMENT_AGENT"], agent: "augment" }, - { envVars: ["OPENCODE_CLIENT"], agent: "opencode" }, - { envVars: ["REPL_ID"], agent: "replit" }, - { - envVars: ["COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"], - agent: "github-copilot", - }, - { envVars: ["GOOSE_TERMINAL"], agent: "goose" }, - { envVars: ["AMP_THREAD_ID"], agent: "amp" }, -]; +export const PROCESS_NAME_AGENTS = new Map([ + ["cursor", "cursor"], + ["claude", "claude"], + ["goose", "goose"], + ["windsurf", "windsurf"], + ["amp", "amp"], + ["codex", "codex"], + ["augment", "augment"], + ["opencode", "opencode"], + ["gemini", "gemini"], +]); + +/** Max levels to walk up the process tree before giving up. */ +const MAX_ANCESTOR_DEPTH = 5; + +/** Pattern to extract `Name:` from `/proc//status`. */ +const PROC_STATUS_NAME_RE = /^Name:\s+(.+)$/m; + +/** Pattern to extract `PPid:` from `/proc//status`. */ +const PROC_STATUS_PPID_RE = /^PPid:\s+(\d+)$/m; + +/** Pattern to parse `ps -o ppid=,comm=` output: " ". */ +const PS_PPID_COMM_RE = /^(\d+)\s+(.+)$/; + +/** Name + parent PID of a process. */ +type ProcessInfo = { + /** Basename of the executable (e.g. "cursor", "bash"). */ + name: string; + /** Parent process ID, or 0 if unavailable. */ + ppid: number; +}; + +/** + * Process info provider signature. Default reads from `/proc/` or `ps(1)`. + * Override via {@link setProcessInfoProvider} for testing. + */ +type ProcessInfoProvider = (pid: number) => ProcessInfo | undefined; + +let _getProcessInfo: ProcessInfoProvider = getProcessInfoFromOS; + +/** + * Override the process info provider. Follows the same pattern as + * {@link setEnv} — call with a mock in tests, reset in `afterEach`. + * + * Pass `getProcessInfoFromOS` to restore the real implementation. + */ +export function setProcessInfoProvider(provider: ProcessInfoProvider): void { + _getProcessInfo = provider; +} /** * Detect which AI agent (if any) is invoking the CLI. * - * Priority: `AI_AGENT` override > specific agent env vars > - * Claude Code (with cowork variant) > `AGENT` generic fallback. + * Priority: + * 1. `AI_AGENT` env var — explicit override, any agent can self-identify + * 2. Agent-specific env vars from {@link ENV_VAR_AGENTS} + * 3. Claude Code with Cowork variant (conditional, can't be in the map) + * 4. Parent process tree — walk ancestors looking for known executables + * 5. `AGENT` env var — generic fallback set by Goose, Amp, and others * * Returns the agent name string, or `undefined` if no agent is detected. */ export function detectAgent(): string | undefined { const env = getEnv(); - // Highest priority: generic override — any agent can self-identify + // 1. Highest priority: explicit override — any agent can self-identify const aiAgent = env.AI_AGENT?.trim(); if (aiAgent) { return aiAgent; } - // Table-driven check for known agents - for (const { envVars, agent } of AGENT_ENV_VARS) { - if (envVars.some((v) => env[v])) { + // 2. Table-driven env var check (Map iteration preserves insertion order) + for (const [envVar, agent] of ENV_VAR_AGENTS) { + if (env[envVar]) { return agent; } } - // Claude Code / Cowork (needs branching logic, so not in the table) + // 3. Claude Code / Cowork — requires branching logic, so not in the map if (env.CLAUDECODE || env.CLAUDE_CODE) { return env.CLAUDE_CODE_IS_COWORK ? "cowork" : "claude"; } - // Lowest priority: generic AGENT fallback (set by Goose, Amp, and others) + // 4. Process tree: walk parent → grandparent → ... looking for known agents + const processAgent = detectAgentFromProcessTree(); + if (processAgent) { + return processAgent; + } + + // 5. Lowest priority: generic AGENT fallback return env.AGENT?.trim() || undefined; } + +/** + * Walk the ancestor process tree looking for known agent executables. + * + * Starts at the direct parent (`process.ppid`) and walks up to + * {@link MAX_ANCESTOR_DEPTH} levels. Stops at PID 1 (init/launchd) + * or on any read error (process exited, permission denied). + * + * On Linux, reads `/proc//status` (in-memory, fast). + * On macOS, falls back to `ps(1)`. + */ +export function detectAgentFromProcessTree(): string | undefined { + let pid = process.ppid; + + for (let depth = 0; depth < MAX_ANCESTOR_DEPTH && pid > 1; depth++) { + const info = _getProcessInfo(pid); + if (!info) { + break; + } + + const agent = PROCESS_NAME_AGENTS.get(info.name.toLowerCase()); + if (agent) { + return agent; + } + + pid = info.ppid; + } + + return; +} + +/** + * Read process name and parent PID for a given PID. + * + * Tries `/proc//status` first (Linux, no subprocess overhead), + * falls back to `ps(1)` (macOS and other Unix systems). + * + * Returns `undefined` if the process doesn't exist or can't be read. + */ +export function getProcessInfoFromOS(pid: number): ProcessInfo | undefined { + // Linux: /proc is an in-memory filesystem — no subprocess needed + try { + const status = readFileSync(`/proc/${pid}/status`, "utf-8"); + const nameMatch = status.match(PROC_STATUS_NAME_RE); + const ppidMatch = status.match(PROC_STATUS_PPID_RE); + if (nameMatch?.[1] && ppidMatch?.[1]) { + return { name: nameMatch[1].trim(), ppid: Number(ppidMatch[1]) }; + } + } catch { + // Not Linux or process is gone — fall through to ps + } + + // macOS / other Unix: use ps(1) + if (process.platform !== "win32") { + try { + const result = execFileSync( + "ps", + ["-p", String(pid), "-o", "ppid=,comm="], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + } + ); + // Output: " 1234 /Applications/Cursor.app/Contents/MacOS/Cursor" + const match = result.trim().match(PS_PPID_COMM_RE); + if (match?.[1] && match?.[2]) { + return { name: basename(match[2].trim()), ppid: Number(match[1]) }; + } + } catch { + // Process gone or ps not available + } + } +} diff --git a/test/lib/detect-agent.test.ts b/test/lib/detect-agent.test.ts index d9a9781e6..74635f130 100644 --- a/test/lib/detect-agent.test.ts +++ b/test/lib/detect-agent.test.ts @@ -1,14 +1,33 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { detectAgent } from "../../src/lib/detect-agent.js"; + +import { + detectAgent, + detectAgentFromProcessTree, + ENV_VAR_AGENTS, + getProcessInfoFromOS, + PROCESS_NAME_AGENTS, + setProcessInfoProvider, +} from "../../src/lib/detect-agent.js"; import { setEnv } from "../../src/lib/env.js"; function withEnv(vars: Record) { setEnv(vars as NodeJS.ProcessEnv); } +/** No-op provider typed to satisfy ProcessInfoProvider. */ +function noProcessInfo(_pid: number): undefined { + return; +} + +/** Disable process tree detection so env-var tests are isolated. */ +function withNoProcessTree() { + setProcessInfoProvider(noProcessInfo); +} + describe("detectAgent", () => { afterEach(() => { setEnv(process.env); + setProcessInfoProvider(getProcessInfoFromOS); }); // ── AI_AGENT override ────────────────────────────────────────────── @@ -19,11 +38,13 @@ describe("detectAgent", () => { }); test("AI_AGENT empty string is ignored", () => { + withNoProcessTree(); withEnv({ AI_AGENT: "" }); expect(detectAgent()).toBeUndefined(); }); test("AI_AGENT whitespace-only is ignored", () => { + withNoProcessTree(); withEnv({ AI_AGENT: " " }); expect(detectAgent()).toBeUndefined(); }); @@ -158,16 +179,19 @@ describe("detectAgent", () => { // ── AGENT generic fallback ───────────────────────────────────────── test("AGENT as generic fallback", () => { + withNoProcessTree(); withEnv({ AGENT: "some-new-agent" }); expect(detectAgent()).toBe("some-new-agent"); }); test("AGENT is trimmed", () => { + withNoProcessTree(); withEnv({ AGENT: " goose " }); expect(detectAgent()).toBe("goose"); }); test("AGENT empty string is ignored", () => { + withNoProcessTree(); withEnv({ AGENT: "" }); expect(detectAgent()).toBeUndefined(); }); @@ -179,8 +203,195 @@ describe("detectAgent", () => { // ── No agent ─────────────────────────────────────────────────────── - test("no env vars → undefined", () => { + test("no env vars → undefined (with process tree disabled)", () => { + withNoProcessTree(); withEnv({}); expect(detectAgent()).toBeUndefined(); }); + + // ── Process tree integration ─────────────────────────────────────── + + test("process tree detection fires between env vars and AGENT fallback", () => { + setProcessInfoProvider((pid) => { + if (pid === process.ppid) { + return { name: "cursor", ppid: 1 }; + } + }); + withEnv({}); + expect(detectAgent()).toBe("cursor"); + }); + + test("env vars take priority over process tree", () => { + setProcessInfoProvider((pid) => { + if (pid === process.ppid) { + return { name: "cursor", ppid: 1 }; + } + }); + withEnv({ GEMINI_CLI: "1" }); + expect(detectAgent()).toBe("gemini"); + }); + + test("AI_AGENT takes priority over process tree", () => { + setProcessInfoProvider((pid) => { + if (pid === process.ppid) { + return { name: "cursor", ppid: 1 }; + } + }); + withEnv({ AI_AGENT: "custom" }); + expect(detectAgent()).toBe("custom"); + }); +}); + +describe("ENV_VAR_AGENTS map structure", () => { + test("is a Map instance", () => { + expect(ENV_VAR_AGENTS).toBeInstanceOf(Map); + }); + + test("all values are non-empty strings", () => { + for (const [envVar, agent] of ENV_VAR_AGENTS) { + expect(envVar.length).toBeGreaterThan(0); + expect(agent.length).toBeGreaterThan(0); + } + }); + + test("env var keys are UPPER_SNAKE_CASE", () => { + for (const envVar of ENV_VAR_AGENTS.keys()) { + expect(envVar).toMatch(/^[A-Z][A-Z0-9_]*$/); + } + }); + + test("agent names are lowercase with optional hyphens", () => { + for (const agent of ENV_VAR_AGENTS.values()) { + expect(agent).toMatch(/^[a-z][a-z0-9-]*$/); + } + }); +}); + +describe("PROCESS_NAME_AGENTS map structure", () => { + test("is a Map instance", () => { + expect(PROCESS_NAME_AGENTS).toBeInstanceOf(Map); + }); + + test("all keys are lowercase", () => { + for (const name of PROCESS_NAME_AGENTS.keys()) { + expect(name).toBe(name.toLowerCase()); + } + }); + + test("agent names are lowercase with optional hyphens", () => { + for (const agent of PROCESS_NAME_AGENTS.values()) { + expect(agent).toMatch(/^[a-z][a-z0-9-]*$/); + } + }); +}); + +describe("detectAgentFromProcessTree", () => { + afterEach(() => { + setProcessInfoProvider(getProcessInfoFromOS); + }); + + test("returns agent when parent matches", () => { + setProcessInfoProvider((pid) => { + if (pid === process.ppid) { + return { name: "cursor", ppid: 1 }; + } + }); + expect(detectAgentFromProcessTree()).toBe("cursor"); + }); + + test("walks up to grandparent", () => { + const shellPid = 100; + setProcessInfoProvider((pid) => { + // parent is bash, grandparent is cursor + if (pid === process.ppid) { + return { name: "bash", ppid: shellPid }; + } + if (pid === shellPid) { + return { name: "Cursor", ppid: 1 }; + } + }); + expect(detectAgentFromProcessTree()).toBe("cursor"); + }); + + test("case-insensitive matching", () => { + setProcessInfoProvider((pid) => { + if (pid === process.ppid) { + return { name: "Claude", ppid: 1 }; + } + }); + expect(detectAgentFromProcessTree()).toBe("claude"); + }); + + test("returns undefined when no agent in tree", () => { + setProcessInfoProvider((pid) => { + if (pid === process.ppid) { + return { name: "bash", ppid: 1 }; + } + }); + expect(detectAgentFromProcessTree()).toBeUndefined(); + }); + + test("stops at PID 1 (init/launchd)", () => { + setProcessInfoProvider((pid) => { + if (pid === process.ppid) { + return { name: "bash", ppid: 1 }; + } + // PID 1 should not be checked + if (pid === 1) { + return { name: "cursor", ppid: 0 }; + } + }); + expect(detectAgentFromProcessTree()).toBeUndefined(); + }); + + test("stops when getProcessInfo returns undefined", () => { + setProcessInfoProvider(noProcessInfo); + expect(detectAgentFromProcessTree()).toBeUndefined(); + }); + + test("respects max depth", () => { + // Create a chain deeper than MAX_ANCESTOR_DEPTH (5) + let nextPid = process.ppid; + const chain = new Map(); + for (let i = 0; i < 10; i++) { + const ppid = nextPid + 1; + chain.set(nextPid, { name: "bash", ppid }); + nextPid = ppid; + } + // Put cursor at depth 8 (beyond the limit) + chain.set(nextPid, { name: "cursor", ppid: 1 }); + + setProcessInfoProvider((pid) => chain.get(pid)); + expect(detectAgentFromProcessTree()).toBeUndefined(); + }); +}); + +describe("getProcessInfoFromOS", () => { + test("returns info for own process", () => { + const info = getProcessInfoFromOS(process.pid); + expect(info).toBeDefined(); + if (info) { + expect(info.name.length).toBeGreaterThan(0); + expect(info.ppid).toBeGreaterThan(0); + } + }); + + test("returns info for parent process", () => { + const info = getProcessInfoFromOS(process.ppid); + expect(info).toBeDefined(); + if (info) { + expect(info.name.length).toBeGreaterThan(0); + expect(info.ppid).toBeGreaterThanOrEqual(0); + } + }); + + test("returns undefined for non-existent PID", () => { + const info = getProcessInfoFromOS(99_999_999); + expect(info).toBeUndefined(); + }); + + test("returns undefined for PID 0", () => { + const info = getProcessInfoFromOS(0); + expect(info).toBeUndefined(); + }); }); From 9be1de9a4a73aca3a41174a0de1492ec658856dc Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 9 Apr 2026 15:51:25 +0000 Subject: [PATCH 4/9] fix(telemetry): remove COPILOT_GITHUB_TOKEN from agent detection Users may export COPILOT_GITHUB_TOKEN persistently in their shell config for authentication purposes, not just when Copilot is actively driving the CLI. This caused false-positive agent tagging. Keep COPILOT_MODEL and COPILOT_ALLOW_ALL which indicate active agent control. --- src/lib/detect-agent.ts | 4 ++-- test/lib/detect-agent.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/detect-agent.ts b/src/lib/detect-agent.ts index e2463ba6e..80d45598d 100644 --- a/src/lib/detect-agent.ts +++ b/src/lib/detect-agent.ts @@ -40,10 +40,10 @@ export const ENV_VAR_AGENTS = new Map([ ["OPENCODE_CLIENT", "opencode"], // Replit ["REPL_ID", "replit"], - // GitHub Copilot + // GitHub Copilot — COPILOT_GITHUB_TOKEN intentionally excluded because + // users may export it persistently for auth, causing false positives ["COPILOT_MODEL", "github-copilot"], ["COPILOT_ALLOW_ALL", "github-copilot"], - ["COPILOT_GITHUB_TOKEN", "github-copilot"], // Goose ["GOOSE_TERMINAL", "goose"], // Amp diff --git a/test/lib/detect-agent.test.ts b/test/lib/detect-agent.test.ts index 74635f130..39e9cba4c 100644 --- a/test/lib/detect-agent.test.ts +++ b/test/lib/detect-agent.test.ts @@ -157,9 +157,10 @@ describe("detectAgent", () => { expect(detectAgent()).toBe("github-copilot"); }); - test("COPILOT_GITHUB_TOKEN → github-copilot", () => { + test("COPILOT_GITHUB_TOKEN alone does not trigger detection (false positive risk)", () => { + withNoProcessTree(); withEnv({ COPILOT_GITHUB_TOKEN: "ghu_xxx" }); - expect(detectAgent()).toBe("github-copilot"); + expect(detectAgent()).toBeUndefined(); }); // ── Goose ────────────────────────────────────────────────────────── From c7854a676c011639541de391a83af0fc2c7cfddb Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 9 Apr 2026 16:00:29 +0000 Subject: [PATCH 5/9] fix(telemetry): add 500ms timeout to ps call in process tree detection Prevents CLI from hanging indefinitely on macOS if ps is unresponsive during agent detection at startup. --- src/lib/detect-agent.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/detect-agent.ts b/src/lib/detect-agent.ts index 80d45598d..a90331566 100644 --- a/src/lib/detect-agent.ts +++ b/src/lib/detect-agent.ts @@ -207,6 +207,9 @@ export function getProcessInfoFromOS(pid: number): ProcessInfo | undefined { { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"], + // Guard against ps hanging on degraded systems — this runs + // synchronously during CLI startup, so keep it tight + timeout: 500, } ); // Output: " 1234 /Applications/Cursor.app/Contents/MacOS/Cursor" From 753fd87125725a9e67c291d2a0eb4fe7ed0a46b2 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 9 Apr 2026 16:10:46 +0000 Subject: [PATCH 6/9] fix(telemetry): remove REPL_ID from agent detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REPL_ID is set in ALL Replit workspaces, not just when the Replit AI agent is driving the CLI. Same class of false positive as COPILOT_GITHUB_TOKEN — a platform environment marker rather than an agent control signal. --- src/lib/detect-agent.ts | 4 ++-- test/lib/detect-agent.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/detect-agent.ts b/src/lib/detect-agent.ts index a90331566..50e270410 100644 --- a/src/lib/detect-agent.ts +++ b/src/lib/detect-agent.ts @@ -38,8 +38,8 @@ export const ENV_VAR_AGENTS = new Map([ ["AUGMENT_AGENT", "augment"], // OpenCode ["OPENCODE_CLIENT", "opencode"], - // Replit - ["REPL_ID", "replit"], + // Replit — REPL_ID intentionally excluded because it's set in ALL Replit + // workspaces, not just when the AI agent is driving the CLI // GitHub Copilot — COPILOT_GITHUB_TOKEN intentionally excluded because // users may export it persistently for auth, causing false positives ["COPILOT_MODEL", "github-copilot"], diff --git a/test/lib/detect-agent.test.ts b/test/lib/detect-agent.test.ts index 39e9cba4c..f63653d6d 100644 --- a/test/lib/detect-agent.test.ts +++ b/test/lib/detect-agent.test.ts @@ -140,9 +140,10 @@ describe("detectAgent", () => { // ── Replit ───────────────────────────────────────────────────────── - test("REPL_ID → replit", () => { + test("REPL_ID alone does not trigger detection (platform env, not agent signal)", () => { + withNoProcessTree(); withEnv({ REPL_ID: "abc123" }); - expect(detectAgent()).toBe("replit"); + expect(detectAgent()).toBeUndefined(); }); // ── GitHub Copilot ───────────────────────────────────────────────── From 8f161b5c0d5e46b0735ec5b30727e98a29007079 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 9 Apr 2026 16:43:15 +0000 Subject: [PATCH 7/9] refactor(telemetry): make process tree detection async MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/lib/detect-agent.ts | 91 ++++++++++++++------------ src/lib/telemetry.ts | 13 +++- test/lib/detect-agent.test.ts | 119 +++++++++++----------------------- 3 files changed, 98 insertions(+), 125 deletions(-) diff --git a/src/lib/detect-agent.ts b/src/lib/detect-agent.ts index 50e270410..4a43f0d25 100644 --- a/src/lib/detect-agent.ts +++ b/src/lib/detect-agent.ts @@ -3,17 +3,18 @@ * a specific AI coding agent. * * Detection uses two strategies: - * 1. **Environment variables** that agents inject into child processes - * (adapted from Vercel's @vercel/detect-agent, Apache-2.0) - * 2. **Process tree walking** — scan parent/grandparent process names - * for known agent executables (fallback when env vars are absent) + * 1. **Environment variables** (sync) — agents inject these into child + * processes. Adapted from Vercel's @vercel/detect-agent (Apache-2.0). + * 2. **Process tree walking** (async) — scan parent/grandparent process + * names for known agent executables. Runs as a non-blocking background + * task so it never delays CLI startup. * * To add a new agent, add entries to {@link ENV_VAR_AGENTS} and/or * {@link PROCESS_NAME_AGENTS}. */ -import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; import { basename } from "node:path"; import { getEnv } from "./env.js"; @@ -87,10 +88,10 @@ type ProcessInfo = { }; /** - * Process info provider signature. Default reads from `/proc/` or `ps(1)`. + * Async process info provider signature. Default reads from `/proc/` or `ps(1)`. * Override via {@link setProcessInfoProvider} for testing. */ -type ProcessInfoProvider = (pid: number) => ProcessInfo | undefined; +type ProcessInfoProvider = (pid: number) => Promise; let _getProcessInfo: ProcessInfoProvider = getProcessInfoFromOS; @@ -105,16 +106,16 @@ export function setProcessInfoProvider(provider: ProcessInfoProvider): void { } /** - * Detect which AI agent (if any) is invoking the CLI. + * Detect agent from environment variables only (synchronous, no I/O). * * Priority: * 1. `AI_AGENT` env var — explicit override, any agent can self-identify * 2. Agent-specific env vars from {@link ENV_VAR_AGENTS} * 3. Claude Code with Cowork variant (conditional, can't be in the map) - * 4. Parent process tree — walk ancestors looking for known executables - * 5. `AGENT` env var — generic fallback set by Goose, Amp, and others + * 4. `AGENT` env var — generic fallback set by Goose, Amp, and others * * Returns the agent name string, or `undefined` if no agent is detected. + * For process tree fallback, use {@link detectAgentFromProcessTree} separately. */ export function detectAgent(): string | undefined { const env = getEnv(); @@ -137,31 +138,28 @@ export function detectAgent(): string | undefined { return env.CLAUDE_CODE_IS_COWORK ? "cowork" : "claude"; } - // 4. Process tree: walk parent → grandparent → ... looking for known agents - const processAgent = detectAgentFromProcessTree(); - if (processAgent) { - return processAgent; - } - - // 5. Lowest priority: generic AGENT fallback + // 4. Lowest priority: generic AGENT fallback return env.AGENT?.trim() || undefined; } /** * Walk the ancestor process tree looking for known agent executables. * - * Starts at the direct parent (`process.ppid`) and walks up to - * {@link MAX_ANCESTOR_DEPTH} levels. Stops at PID 1 (init/launchd) - * or on any read error (process exited, permission denied). + * Fully async — never blocks CLI startup. Starts at the direct parent + * (`process.ppid`) and walks up to {@link MAX_ANCESTOR_DEPTH} levels. + * Stops at PID 1 (init/launchd) or on any read error. * - * On Linux, reads `/proc//status` (in-memory, fast). - * On macOS, falls back to `ps(1)`. + * - **Linux**: reads `/proc//status` (in-memory filesystem, fast). + * - **macOS**: uses `ps(1)` with a 500ms timeout per invocation. + * - **Windows**: not supported (env var detection still works). */ -export function detectAgentFromProcessTree(): string | undefined { +export async function detectAgentFromProcessTree(): Promise< + string | undefined +> { let pid = process.ppid; for (let depth = 0; depth < MAX_ANCESTOR_DEPTH && pid > 1; depth++) { - const info = _getProcessInfo(pid); + const info = await _getProcessInfo(pid); if (!info) { break; } @@ -182,13 +180,14 @@ export function detectAgentFromProcessTree(): string | undefined { * * Tries `/proc//status` first (Linux, no subprocess overhead), * falls back to `ps(1)` (macOS and other Unix systems). - * - * Returns `undefined` if the process doesn't exist or can't be read. + * Windows is unsupported — returns `undefined`. */ -export function getProcessInfoFromOS(pid: number): ProcessInfo | undefined { - // Linux: /proc is an in-memory filesystem — no subprocess needed +export async function getProcessInfoFromOS( + pid: number +): Promise { + // Linux: /proc is an in-memory filesystem — fast even though async try { - const status = readFileSync(`/proc/${pid}/status`, "utf-8"); + const status = await readFile(`/proc/${pid}/status`, "utf-8"); const nameMatch = status.match(PROC_STATUS_NAME_RE); const ppidMatch = status.match(PROC_STATUS_PPID_RE); if (nameMatch?.[1] && ppidMatch?.[1]) { @@ -198,27 +197,37 @@ export function getProcessInfoFromOS(pid: number): ProcessInfo | undefined { // Not Linux or process is gone — fall through to ps } - // macOS / other Unix: use ps(1) + // macOS / other Unix: use ps(1) asynchronously if (process.platform !== "win32") { try { - const result = execFileSync( + const result = await execFilePromise( "ps", ["-p", String(pid), "-o", "ppid=,comm="], - { - encoding: "utf-8", - stdio: ["pipe", "pipe", "ignore"], - // Guard against ps hanging on degraded systems — this runs - // synchronously during CLI startup, so keep it tight - timeout: 500, - } + { timeout: 500 } ); - // Output: " 1234 /Applications/Cursor.app/Contents/MacOS/Cursor" const match = result.trim().match(PS_PPID_COMM_RE); if (match?.[1] && match?.[2]) { return { name: basename(match[2].trim()), ppid: Number(match[1]) }; } } catch { - // Process gone or ps not available + // Process gone, ps not available, or timeout } } } + +/** Promisified `execFile` — resolves with stdout, rejects on error/timeout. */ +function execFilePromise( + cmd: string, + args: readonly string[], + opts: { timeout?: number } +): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, { encoding: "utf-8", ...opts }, (err, stdout) => { + if (err) { + reject(err); + } else { + resolve(stdout); + } + }); + }); +} diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 6c7f1c61e..2181ef509 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -19,7 +19,7 @@ import { SENTRY_CLI_DSN, } from "./constants.js"; import { isReadonlyError, tryRepairAndRetry } from "./db/schema.js"; -import { detectAgent } from "./detect-agent.js"; +import { detectAgent, detectAgentFromProcessTree } from "./detect-agent.js"; import { getEnv } from "./env.js"; import { ApiError, AuthError, OutputError } from "./errors.js"; import { attachSentryReporter } from "./logger.js"; @@ -523,10 +523,19 @@ export function initSentry( // Tag whether running in an interactive terminal or agent/CI environment Sentry.setTag("is_tty", !!process.stdout.isTTY); - // Tag which AI agent (if any) is driving the CLI + // Tag which AI agent (if any) is driving the CLI. + // Env var detection is sync (instant). If no env var matches, fire off + // async process tree detection in the background — it sets the tag + // before the transaction finishes without blocking CLI startup. const agent = detectAgent(); if (agent) { Sentry.setTag("agent", agent); + } else { + detectAgentFromProcessTree().then((processAgent) => { + if (processAgent) { + Sentry.setTag("agent", processAgent); + } + }); } // Wire up consola → Sentry log forwarding now that the client is active diff --git a/test/lib/detect-agent.test.ts b/test/lib/detect-agent.test.ts index f63653d6d..59d0dc074 100644 --- a/test/lib/detect-agent.test.ts +++ b/test/lib/detect-agent.test.ts @@ -14,16 +14,11 @@ function withEnv(vars: Record) { setEnv(vars as NodeJS.ProcessEnv); } -/** No-op provider typed to satisfy ProcessInfoProvider. */ -function noProcessInfo(_pid: number): undefined { +/** No-op async provider typed to satisfy ProcessInfoProvider. */ +async function noProcessInfo(_pid: number): Promise { return; } -/** Disable process tree detection so env-var tests are isolated. */ -function withNoProcessTree() { - setProcessInfoProvider(noProcessInfo); -} - describe("detectAgent", () => { afterEach(() => { setEnv(process.env); @@ -38,13 +33,11 @@ describe("detectAgent", () => { }); test("AI_AGENT empty string is ignored", () => { - withNoProcessTree(); withEnv({ AI_AGENT: "" }); expect(detectAgent()).toBeUndefined(); }); test("AI_AGENT whitespace-only is ignored", () => { - withNoProcessTree(); withEnv({ AI_AGENT: " " }); expect(detectAgent()).toBeUndefined(); }); @@ -138,14 +131,18 @@ describe("detectAgent", () => { expect(detectAgent()).toBe("cowork"); }); - // ── Replit ───────────────────────────────────────────────────────── + // ── Excluded env vars (false positive risks) ────────────────────── test("REPL_ID alone does not trigger detection (platform env, not agent signal)", () => { - withNoProcessTree(); withEnv({ REPL_ID: "abc123" }); expect(detectAgent()).toBeUndefined(); }); + test("COPILOT_GITHUB_TOKEN alone does not trigger detection (false positive risk)", () => { + withEnv({ COPILOT_GITHUB_TOKEN: "ghu_xxx" }); + expect(detectAgent()).toBeUndefined(); + }); + // ── GitHub Copilot ───────────────────────────────────────────────── test("COPILOT_MODEL → github-copilot", () => { @@ -158,12 +155,6 @@ describe("detectAgent", () => { expect(detectAgent()).toBe("github-copilot"); }); - test("COPILOT_GITHUB_TOKEN alone does not trigger detection (false positive risk)", () => { - withNoProcessTree(); - withEnv({ COPILOT_GITHUB_TOKEN: "ghu_xxx" }); - expect(detectAgent()).toBeUndefined(); - }); - // ── Goose ────────────────────────────────────────────────────────── test("GOOSE_TERMINAL → goose", () => { @@ -181,19 +172,16 @@ describe("detectAgent", () => { // ── AGENT generic fallback ───────────────────────────────────────── test("AGENT as generic fallback", () => { - withNoProcessTree(); withEnv({ AGENT: "some-new-agent" }); expect(detectAgent()).toBe("some-new-agent"); }); test("AGENT is trimmed", () => { - withNoProcessTree(); withEnv({ AGENT: " goose " }); expect(detectAgent()).toBe("goose"); }); test("AGENT empty string is ignored", () => { - withNoProcessTree(); withEnv({ AGENT: "" }); expect(detectAgent()).toBeUndefined(); }); @@ -205,43 +193,10 @@ describe("detectAgent", () => { // ── No agent ─────────────────────────────────────────────────────── - test("no env vars → undefined (with process tree disabled)", () => { - withNoProcessTree(); + test("no env vars → undefined", () => { withEnv({}); expect(detectAgent()).toBeUndefined(); }); - - // ── Process tree integration ─────────────────────────────────────── - - test("process tree detection fires between env vars and AGENT fallback", () => { - setProcessInfoProvider((pid) => { - if (pid === process.ppid) { - return { name: "cursor", ppid: 1 }; - } - }); - withEnv({}); - expect(detectAgent()).toBe("cursor"); - }); - - test("env vars take priority over process tree", () => { - setProcessInfoProvider((pid) => { - if (pid === process.ppid) { - return { name: "cursor", ppid: 1 }; - } - }); - withEnv({ GEMINI_CLI: "1" }); - expect(detectAgent()).toBe("gemini"); - }); - - test("AI_AGENT takes priority over process tree", () => { - setProcessInfoProvider((pid) => { - if (pid === process.ppid) { - return { name: "cursor", ppid: 1 }; - } - }); - withEnv({ AI_AGENT: "custom" }); - expect(detectAgent()).toBe("custom"); - }); }); describe("ENV_VAR_AGENTS map structure", () => { @@ -292,18 +247,18 @@ describe("detectAgentFromProcessTree", () => { setProcessInfoProvider(getProcessInfoFromOS); }); - test("returns agent when parent matches", () => { - setProcessInfoProvider((pid) => { + test("returns agent when parent matches", async () => { + setProcessInfoProvider(async (pid) => { if (pid === process.ppid) { return { name: "cursor", ppid: 1 }; } }); - expect(detectAgentFromProcessTree()).toBe("cursor"); + expect(await detectAgentFromProcessTree()).toBe("cursor"); }); - test("walks up to grandparent", () => { + test("walks up to grandparent", async () => { const shellPid = 100; - setProcessInfoProvider((pid) => { + setProcessInfoProvider(async (pid) => { // parent is bash, grandparent is cursor if (pid === process.ppid) { return { name: "bash", ppid: shellPid }; @@ -312,29 +267,29 @@ describe("detectAgentFromProcessTree", () => { return { name: "Cursor", ppid: 1 }; } }); - expect(detectAgentFromProcessTree()).toBe("cursor"); + expect(await detectAgentFromProcessTree()).toBe("cursor"); }); - test("case-insensitive matching", () => { - setProcessInfoProvider((pid) => { + test("case-insensitive matching", async () => { + setProcessInfoProvider(async (pid) => { if (pid === process.ppid) { return { name: "Claude", ppid: 1 }; } }); - expect(detectAgentFromProcessTree()).toBe("claude"); + expect(await detectAgentFromProcessTree()).toBe("claude"); }); - test("returns undefined when no agent in tree", () => { - setProcessInfoProvider((pid) => { + test("returns undefined when no agent in tree", async () => { + setProcessInfoProvider(async (pid) => { if (pid === process.ppid) { return { name: "bash", ppid: 1 }; } }); - expect(detectAgentFromProcessTree()).toBeUndefined(); + expect(await detectAgentFromProcessTree()).toBeUndefined(); }); - test("stops at PID 1 (init/launchd)", () => { - setProcessInfoProvider((pid) => { + test("stops at PID 1 (init/launchd)", async () => { + setProcessInfoProvider(async (pid) => { if (pid === process.ppid) { return { name: "bash", ppid: 1 }; } @@ -343,15 +298,15 @@ describe("detectAgentFromProcessTree", () => { return { name: "cursor", ppid: 0 }; } }); - expect(detectAgentFromProcessTree()).toBeUndefined(); + expect(await detectAgentFromProcessTree()).toBeUndefined(); }); - test("stops when getProcessInfo returns undefined", () => { + test("stops when getProcessInfo returns undefined", async () => { setProcessInfoProvider(noProcessInfo); - expect(detectAgentFromProcessTree()).toBeUndefined(); + expect(await detectAgentFromProcessTree()).toBeUndefined(); }); - test("respects max depth", () => { + test("respects max depth", async () => { // Create a chain deeper than MAX_ANCESTOR_DEPTH (5) let nextPid = process.ppid; const chain = new Map(); @@ -363,14 +318,14 @@ describe("detectAgentFromProcessTree", () => { // Put cursor at depth 8 (beyond the limit) chain.set(nextPid, { name: "cursor", ppid: 1 }); - setProcessInfoProvider((pid) => chain.get(pid)); - expect(detectAgentFromProcessTree()).toBeUndefined(); + setProcessInfoProvider(async (pid) => chain.get(pid)); + expect(await detectAgentFromProcessTree()).toBeUndefined(); }); }); describe("getProcessInfoFromOS", () => { - test("returns info for own process", () => { - const info = getProcessInfoFromOS(process.pid); + test("returns info for own process", async () => { + const info = await getProcessInfoFromOS(process.pid); expect(info).toBeDefined(); if (info) { expect(info.name.length).toBeGreaterThan(0); @@ -378,8 +333,8 @@ describe("getProcessInfoFromOS", () => { } }); - test("returns info for parent process", () => { - const info = getProcessInfoFromOS(process.ppid); + test("returns info for parent process", async () => { + const info = await getProcessInfoFromOS(process.ppid); expect(info).toBeDefined(); if (info) { expect(info.name.length).toBeGreaterThan(0); @@ -387,13 +342,13 @@ describe("getProcessInfoFromOS", () => { } }); - test("returns undefined for non-existent PID", () => { - const info = getProcessInfoFromOS(99_999_999); + test("returns undefined for non-existent PID", async () => { + const info = await getProcessInfoFromOS(99_999_999); expect(info).toBeUndefined(); }); - test("returns undefined for PID 0", () => { - const info = getProcessInfoFromOS(0); + test("returns undefined for PID 0", async () => { + const info = await getProcessInfoFromOS(0); expect(info).toBeUndefined(); }); }); From 2e2d1387e9291fb4182d2c86003a9dc543ff146a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 9 Apr 2026 16:51:21 +0000 Subject: [PATCH 8/9] fix(telemetry): add .catch() to fire-and-forget process tree detection Prevents unhandled promise rejection if the async detection fails, particularly important in library mode where OnUnhandledRejection is excluded. --- src/lib/telemetry.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 2181ef509..739eb4264 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -531,11 +531,15 @@ export function initSentry( if (agent) { Sentry.setTag("agent", agent); } else { - detectAgentFromProcessTree().then((processAgent) => { - if (processAgent) { - Sentry.setTag("agent", processAgent); - } - }); + detectAgentFromProcessTree() + .then((processAgent) => { + if (processAgent) { + Sentry.setTag("agent", processAgent); + } + }) + .catch(() => { + // Best-effort — swallow errors silently + }); } // Wire up consola → Sentry log forwarding now that the client is active From e1d4849e565287e13621c696f75b9e38bb944849 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 9 Apr 2026 17:15:46 +0000 Subject: [PATCH 9/9] fix(telemetry): unref child process and warn on process tree failure - Replace promisify(execFile) with a manual wrapper that calls child.unref() so the spawned ps process never prevents CLI exit - Log a warning via logger.withTag('agent') instead of silently swallowing errors in the .catch() handler --- src/lib/detect-agent.ts | 27 ++++++++++++++++++--------- src/lib/telemetry.ts | 6 +++--- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/lib/detect-agent.ts b/src/lib/detect-agent.ts index 4a43f0d25..2ebc349e9 100644 --- a/src/lib/detect-agent.ts +++ b/src/lib/detect-agent.ts @@ -200,7 +200,7 @@ export async function getProcessInfoFromOS( // macOS / other Unix: use ps(1) asynchronously if (process.platform !== "win32") { try { - const result = await execFilePromise( + const result = await execFileUnreffed( "ps", ["-p", String(pid), "-o", "ppid=,comm="], { timeout: 500 } @@ -215,19 +215,28 @@ export async function getProcessInfoFromOS( } } -/** Promisified `execFile` — resolves with stdout, rejects on error/timeout. */ -function execFilePromise( +/** + * Spawn `execFile` with the child process unreffed so it never + * prevents the CLI from exiting. Resolves with stdout on success. + */ +function execFileUnreffed( cmd: string, args: readonly string[], opts: { timeout?: number } ): Promise { return new Promise((resolve, reject) => { - execFile(cmd, args, { encoding: "utf-8", ...opts }, (err, stdout) => { - if (err) { - reject(err); - } else { - resolve(stdout); + const child = execFile( + cmd, + args, + { encoding: "utf-8", ...opts }, + (err, stdout) => { + if (err) { + reject(err); + } else { + resolve(stdout); + } } - }); + ); + child.unref(); }); } diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 739eb4264..d8ecc3ad1 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -22,7 +22,7 @@ import { isReadonlyError, tryRepairAndRetry } from "./db/schema.js"; import { detectAgent, detectAgentFromProcessTree } from "./detect-agent.js"; import { getEnv } from "./env.js"; import { ApiError, AuthError, OutputError } from "./errors.js"; -import { attachSentryReporter } from "./logger.js"; +import { attachSentryReporter, logger } from "./logger.js"; import { getSentryBaseUrl, isSentrySaasUrl } from "./sentry-urls.js"; import { getRealUsername } from "./utils.js"; @@ -537,8 +537,8 @@ export function initSentry( Sentry.setTag("agent", processAgent); } }) - .catch(() => { - // Best-effort — swallow errors silently + .catch((error) => { + logger.withTag("agent").warn("Process tree detection failed:", error); }); }