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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/src/cli.agentMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function runAsAgent(args: string[], { ci = true } = {}): Promise<Run> {
HOME: home,
USERPROFILE: home,
...(ci ? { CI: "1" } : {}),
CLAUDECODE: "1",
CLAUDE_CODE_CHILD_SESSION: "1",
},
});
let stdout = "";
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/commands/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ describe("auth login", () => {
"GITPOD_WORKSPACE_ID",
"container",
"CLAUDECODE",
"CLAUDE_CODE_CHILD_SESSION",
"CLAUDE_CODE_ENTRYPOINT",
].map((key) => [key, process.env[key]]),
);
Expand Down Expand Up @@ -321,14 +322,14 @@ describe("auth login", () => {
});

it("lets a coding agent on this machine open the browser sign-in without a terminal", async () => {
process.env["CLAUDECODE"] = "1";
process.env["CLAUDE_CODE_CHILD_SESSION"] = "1";
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: undefined });
await runCommand({});
expect(browserAuth.start).toHaveBeenCalledOnce();
});

it("keeps a coding agent in CI off the browser sign-in", async () => {
process.env["CLAUDECODE"] = "1";
process.env["CLAUDE_CODE_CHILD_SESSION"] = "1";
process.env["CI"] = "true";
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: undefined });
await expect(runCommand({})).rejects.toThrow(/Invalid command usage/);
Expand Down
57 changes: 46 additions & 11 deletions packages/cli/src/telemetry/agent_runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const VENDOR_ENV_KEYS = [
"BOBSHELL_API_KEY",
"BOBSHELL_NO_RELAUNCH",
"CLAUDECODE",
"CLAUDE_CODE_CHILD_SESSION",
"CLAUDE_CODE_ENTRYPOINT",
"CODEX_THREAD_ID",
"CODEX_CI",
Expand Down Expand Up @@ -40,6 +41,11 @@ const VENDOR_ENV_KEYS = [
"QODER_AGENT",
"AGENT_DISPLAY_OUT",
"AGENT_CONTEXT_OUT",
"CURSOR_CONVERSATION_ID",
"CURSOR_SANDBOX",
"CLINE_WRAPPER_PATH",
"MUSE_RELEASE_INFO",
"OZ_RUN_ID",
"DSH_SHELL",
"AGENT",
"AI_AGENT",
Expand Down Expand Up @@ -68,7 +74,7 @@ describe("detectAgentRuntime — base behavior", () => {
it("first matching vendor wins (rule order)", async () => {
// Claude Code marker set alongside a Codex marker — Claude Code is the
// first rule, so it wins.
process.env["CLAUDECODE"] = "1";
process.env["CLAUDE_CODE_CHILD_SESSION"] = "1";
process.env["CODEX_THREAD_ID"] = "thread-1";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("claude_code");
Expand All @@ -83,7 +89,7 @@ describe("detectAgentRuntime — base behavior", () => {
});

it.each([
["CLAUDECODE", "claude_code"],
["CLAUDE_CODE_CHILD_SESSION", "claude_code"],
["CODEX_THREAD_ID", "codex"],
])("keeps child-agent attribution for %s inside Cursor", async (key, runtime) => {
process.env["CURSOR_AGENT"] = "1";
Expand All @@ -98,7 +104,7 @@ describe("detectAgentRuntime — base behavior", () => {
process.env["HARBOR_AGENT"] = "private-harness-value";
expect(detectExecutionHarnessHint()).toBe("harbor");
expect(detectAgentRuntime()).toBeNull();
process.env["CLAUDECODE"] = "1";
process.env["CLAUDE_CODE_CHILD_SESSION"] = "1";
expect(detectAgentRuntime()).toBe("claude_code");
expect(detectExecutionHarnessHint()).toBe("harbor");
});
Expand Down Expand Up @@ -129,7 +135,7 @@ describe("detectAgentRuntime — IBM Bob Shell", () => {

it("wins over inherited outer-agent markers as the immediate executor", async () => {
process.env["BOBSHELL_CLI"] = "1";
process.env["CLAUDECODE"] = "1";
process.env["CLAUDE_CODE_CHILD_SESSION"] = "1";
process.env["CODEX_THREAD_ID"] = "thread-1";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("bob");
Expand All @@ -150,8 +156,8 @@ describe("detectAgentRuntime — Claude Code", () => {
process.env = { ...savedEnv };
});

it("detects via CLAUDECODE=1", async () => {
process.env["CLAUDECODE"] = "1";
it("detects via CLAUDE_CODE_CHILD_SESSION=1", async () => {
process.env["CLAUDE_CODE_CHILD_SESSION"] = "1";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("claude_code");
});
Expand Down Expand Up @@ -384,10 +390,10 @@ describe("detectAgentRuntime — Gemini managed agent", () => {
});

it("returns gemini_managed_agent over an env-var rule when both signals match", async () => {
// If a user happens to set CLAUDECODE=1 inside a Gemini sandbox (or any
// If a user happens to set a Claude Code marker inside a Gemini sandbox (or any
// odd config), the filesystem+kernel signal wins — Gemini is more
// specific than a generic env-var marker.
process.env["CLAUDECODE"] = "1";
process.env["CLAUDE_CODE_CHILD_SESSION"] = "1";
vi.doMock("node:os", async () => {
const actual = await vi.importActual<typeof import("node:os")>("node:os");
return { ...actual, release: () => "4.19.0-gvisor", platform: () => "linux" };
Expand Down Expand Up @@ -566,8 +572,37 @@ describe("detectAgentRuntime: agents seen in discovery hints", () => {
expect(detectAgentRuntime()).toBe(runtime);
});

it("lets Claude Code's own shell marker beat the Agent SDK version key", async () => {
it("does not treat a Claude IDE extension terminal (CLAUDECODE only) as an agent", async () => {
process.env["CLAUDECODE"] = "1";
const { detectAgentRuntime, isAgentDriven } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBeNull();
expect(isAgentDriven()).toBe(false);
});

it.each([
["AUGMENT_AGENT", "1", "auggie"],
["AMP_CURRENT_THREAD_ID", "T-1", "amp"],
["CURSOR_CONVERSATION_ID", "c", "cursor"],
["CURSOR_SANDBOX", "native", "cursor"],
["CLINE_WRAPPER_PATH", "/usr/lib/cline", "cline"],
["MUSE_RELEASE_INFO", "r", "muse"],
["OZ_RUN_ID", "run", "warp_oz"],
])("detects %s=%s as %s", async (key, value, runtime) => {
process.env[key] = value;
const { detectAgentRuntime, isAgentDriven } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe(runtime);
expect(isAgentDriven()).toBe(true);
});

it("lets the harness a Warp Oz run uses win over OZ_RUN_ID", async () => {
process.env["OZ_RUN_ID"] = "run";
process.env["CODEX_THREAD_ID"] = "t";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("codex");
});

it("lets Claude Code's own shell marker beat the Agent SDK version key", async () => {
process.env["CLAUDE_CODE_CHILD_SESSION"] = "1";
process.env["CLAUDE_AGENT_SDK_VERSION"] = "0.2.0";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("claude_code");
Expand Down Expand Up @@ -618,7 +653,7 @@ describe("detectAgentRuntime: AI_AGENT fallback", () => {
});

it("lets a vendor marker beat an inherited AI_AGENT value", async () => {
process.env["CLAUDECODE"] = "1";
process.env["CLAUDE_CODE_CHILD_SESSION"] = "1";
process.env["AI_AGENT"] = "opencode";
const { detectAgentRuntime } = await import("./agent_runtime.js");
expect(detectAgentRuntime()).toBe("claude_code");
Expand Down Expand Up @@ -902,7 +937,7 @@ describe("detectSandboxRuntime — kernel-string path", () => {

describe("isAgentDriven", () => {
it.each([
[{ CLAUDECODE: "1" }, true],
[{ CLAUDE_CODE_CHILD_SESSION: "1" }, true],
[{ CODEX_THREAD_ID: "t" }, true],
[{ CURSOR_AGENT: "1", TERM_PROGRAM: "cursor" }, true],
[{ TERM_PROGRAM: "cursor" }, false],
Expand Down
64 changes: 44 additions & 20 deletions packages/cli/src/telemetry/agent_runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export type AgentRuntime =
| "v0"
| "rovodev"
| "base44"
| "auggie"
| "muse"
| "warp_oz"
| "other_ai_agent"
| null;

Expand All @@ -89,14 +92,15 @@ const VENDOR_RULES: VendorRule[] = [
name: "bob",
check: (env) => typeof env["BOBSHELL_CLI"] === "string",
},
// Anthropic Claude Code — sets CLAUDECODE=1 on every Bash/PowerShell tool
// spawn (Shell.ts:321) and CLAUDE_CODE_ENTRYPOINT at startup, inherited by
// every child (main.tsx:527). Both propagate to spawned subprocesses.
// Source: confirmed by @magi from Claude Code internal source.
// Claude Code sets CLAUDE_CODE_CHILD_SESSION on what its tools spawn, and an
// inherited CLAUDE_CODE_ENTRYPOINT (older versions). Not CLAUDECODE: its IDE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: CLAUDE_CODE_ENTRYPOINT is not only from older versions. Current claude (2.1.319) sets it to cli/sdk-cli/mcp at startup. It is also the only Claude marker that reaches a stdio MCP server (Claude Code removes CHILD_SESSION there) and shells from versions before 2.1.172. Calling it "older versions" invites someone to delete it later, which would break detection in exactly those cases.

Suggested change
// inherited CLAUDE_CODE_ENTRYPOINT (older versions). Not CLAUDECODE: its IDE
// inherited CLAUDE_CODE_ENTRYPOINT (MCP servers, pre-2.1.172). Not CLAUDECODE: its IDE

// extensions also set that in the terminal a person types into.
// Source: https://code.claude.com/docs/en/env-vars
{
name: "claude_code",
check: (env) =>
typeof env["CLAUDECODE"] === "string" || typeof env["CLAUDE_CODE_ENTRYPOINT"] === "string",
typeof env["CLAUDE_CODE_CHILD_SESSION"] === "string" ||
typeof env["CLAUDE_CODE_ENTRYPOINT"] === "string",
},
// OpenAI Codex (https://github.com/openai/codex).
// - CODEX_THREAD_ID — set unconditionally on every spawned shell command
Expand All @@ -116,16 +120,23 @@ const VENDOR_RULES: VendorRule[] = [
typeof env["CODEX_CI"] === "string" ||
typeof env["CODEX_SANDBOX_NETWORK_DISABLED"] === "string",
},
// Cursor Agent marks its shell with CURSOR_AGENT. Keep this after
// Claude/Codex for nested agents.
// Source: https://docs.cursor.com/en/agent/terminal
{ name: "cursor", check: (env) => Boolean(env["CURSOR_AGENT"]) },
// Nous Research Hermes Agent — cli.py:50 unconditionally executes
// Cursor Agent sets CURSOR_AGENT, CURSOR_CONVERSATION_ID per command and
// CURSOR_SANDBOX on sandboxed ones. After Claude/Codex for nested agents.
// Source: https://docs.cursor.com/en/agent/terminal,
// https://cursor.com/docs/agent/security/run-modes
{
name: "cursor",
check: (env) =>
Boolean(env["CURSOR_AGENT"]) ||
typeof env["CURSOR_CONVERSATION_ID"] === "string" ||
typeof env["CURSOR_SANDBOX"] === "string",
},
// Nous Research Hermes Agent — its cli.py unconditionally executes
// os.environ["HERMES_QUIET"] = "1"
// at module load, so the marker propagates via os.environ to every
// subprocess spawned by Hermes. Keying on existence (not the literal
// "1") so we still match if Hermes ever changes the value.
// Source: https://github.com/NousResearch/hermes-agent (cli.py:50)
// Source: https://github.com/NousResearch/hermes-agent/blob/main/cli.py#L50
{
name: "hermes",
check: (env) => typeof env["HERMES_QUIET"] === "string",
Expand All @@ -148,15 +159,17 @@ const VENDOR_RULES: VendorRule[] = [
},
// Cline (cline/cline) VS Code extension — injects CLINE_ACTIVE=true into the
// integrated terminal via vscode.TerminalOptions.env, which the terminal
// exports to every shell command run in it
// (apps/vscode/src/hosts/vscode/terminal/VscodeTerminalRegistry.ts:29).
// exports to every shell command run in it.
// Caveat: present only on the default "vscodeTerminal" exec path — the opt-in
// backgroundExec/YOLO path spawns via child_process without the marker. Same
// integrated-terminal-only scope as the Cursor/Windsurf rules above.
// Source: https://github.com/cline/cline (VscodeTerminalRegistry.ts:29)
// integrated-terminal-only scope as the Cursor/Windsurf rules in WORKSPACE_RULES.
// The npm Cline CLI instead adds CLINE_WRAPPER_PATH to its child env, which
// its bash tool passes on (apps/cli/bin/cline).
// Source: https://github.com/cline/cline/blob/main/apps/vscode/src/hosts/vscode/terminal/VscodeTerminalRegistry.ts
{
name: "cline",
check: (env) => typeof env["CLINE_ACTIVE"] === "string",
check: (env) =>
typeof env["CLINE_ACTIVE"] === "string" || typeof env["CLINE_WRAPPER_PATH"] === "string",
},
// Google Gemini CLI (open-source @google/gemini-cli) — DISTINCT from the
// Gemini managed-agent sandbox. (If a /.agents/ filesystem detector is
Expand Down Expand Up @@ -235,6 +248,15 @@ const VENDOR_RULES: VendorRule[] = [
name: "kiro",
check: (env) => Boolean(env["AGENT_DISPLAY_OUT"]) || Boolean(env["AGENT_CONTEXT_OUT"]),
},
// Auggie (Augment CLI): its shell tool runs commands with AUGMENT_AGENT=1
// (@augmentcode/auggie bundle, augment.mjs).
{ name: "auggie", check: (env) => typeof env["AUGMENT_AGENT"] === "string" },
// Amp: the Bash tool and toolbox subprocesses get AMP_CURRENT_THREAD_ID
// (@sourcegraph/amp bundle, dist/main.js).
{ name: "amp", check: (env) => typeof env["AMP_CURRENT_THREAD_ID"] === "string" },
// Muse Code: Meta's launcher exports MUSE_RELEASE_INFO, then execs the agent.
// Source: https://api.meta.ai/muse-launcher.sh
{ name: "muse", check: (env) => typeof env["MUSE_RELEASE_INFO"] === "string" },
// Closed-source agents with no public marker. Each key below shows up in this
// CLI's own agent_env_hints on non-interactive runs, which is direct evidence
// that it is in the env of the shell the agent spawned. Only key presence is
Expand Down Expand Up @@ -302,6 +324,9 @@ const VENDOR_RULES: VendorRule[] = [
name: "openhands",
check: (env) => env["AI_AGENT"]?.trim().toLowerCase() === "openhands",
},
// Warp Oz: OZ_RUN_ID wraps a Claude/Codex/Gemini run, so it follows them.
// Source: https://github.com/warpdotdev/warp (crates/warp_cli/src/lib.rs)
{ name: "warp_oz", check: (env) => typeof env["OZ_RUN_ID"] === "string" },
// An app built on the Claude Agent SDK, running a command outside Claude
// Code's own shell tool (that path sets CLAUDECODE, caught above). The SDK

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this comment is out of date now that the rule no longer reads CLAUDECODE.

Suggested change
// Code's own shell tool (that path sets CLAUDECODE, caught above). The SDK
// Code's own shell tool (that path sets CLAUDE_CODE_CHILD_SESSION, caught above). The SDK

// always sets CLAUDE_AGENT_SDK_VERSION on the process it spawns. After the
Expand Down Expand Up @@ -407,11 +432,10 @@ function aiAgentFallback(env: NodeJS.ProcessEnv): AgentRuntime {
// defaults true), which sets no marker.
// - Jules / Factory Droid — no verifiable unconditional runtime marker
// (Jules is a closed sandbox; Factory's FACTORY_PROJECT_DIR /
// DROID_PLUGIN_ROOT are hook-scoped only). Amp and Devin are detected by
// the agent names they report instead (AI_AGENT_RUNTIMES).
// DROID_PLUGIN_ROOT are hook-scoped only). Devin is detected by the agent
// name it reports instead (AI_AGENT_RUNTIMES).
// - Seen only in a shipped bundle: Grok GROK_AGENT (also conflicts with an
// older probe; GROK_SESSION_ID is used), Augment AUGMENT_AGENT, Factory
// FACTORY_SESSION_ID.
// older probe; GROK_SESSION_ID is used), Factory FACTORY_SESSION_ID.
// - Third-party detector packages only: Trae TRAE_AI_SHELL_ID, Windsurf
// WINDSURF_CASCADE_TERMINAL (plus a forum post).
// - Kimi Code: its bash tool sets no marker (MoonshotAI/kimi-code bashTool.ts);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/telemetry/system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ describe("getSystemMeta execution context", () => {
it("retains a harness marker after the child agent is recognized", async () => {
const savedEnv = { ...process.env };
try {
process.env["CLAUDECODE"] = "1";
process.env["CLAUDE_CODE_CHILD_SESSION"] = "1";
process.env["HARBOR_AGENT"] = "private-harness-value";
const { getSystemMeta } = await import("./system.js");
const meta = getSystemMeta();
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/attendedTerminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe("isUnattended", () => {
async (name, value) => {
for (const key of Object.keys(process.env)) {
if (
/^(CLAUDE|CODEX_|CURSOR_|BOBSHELL|GEMINI_CLI|CLINE_|HERMES_|PI_CODING|CRUSH|OPENCLAW|AI_AGENT$|AGENT$|WORKBUDDY_|CODEBUDDY_|ICUBE_|TRAE_|AUTO_PIPPIT|PIPPIT_|DOUBAO_|AILY_|GROK_|KIMI_AGENT|ZCODE_|SWE_AGENT|DSH_|COPILOT_|KILO|OPENCODE|QWEN_CODE|JUNIE_|ANTIGRAVITY_|QODER_|AGENT_(DISPLAY|CONTEXT)_OUT)/.test(
/^(CLAUDE|CODEX_|CURSOR_|BOBSHELL|GEMINI_CLI|CLINE_|HERMES_|PI_CODING|CRUSH|OPENCLAW|AI_AGENT$|AGENT$|WORKBUDDY_|CODEBUDDY_|ICUBE_|TRAE_|AUTO_PIPPIT|PIPPIT_|DOUBAO_|AILY_|GROK_|KIMI_AGENT|ZCODE_|SWE_AGENT|AUGMENT_AGENT|AMP_CURRENT|MUSE_RELEASE|OZ_RUN|DSH_|COPILOT_|KILO|OPENCODE|QWEN_CODE|JUNIE_|ANTIGRAVITY_|QODER_|AGENT_(DISPLAY|CONTEXT)_OUT)/.test(
key,
)
) {
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/utils/desktopApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,13 @@ describe("desktopHint", () => {
});

it("still gives the line to a coding agent, which passes it on to the person", () => {
const agent = { ...mac, ready: true, installed: true, tty: false, env: { CLAUDECODE: "1" } };
const agent = {
...mac,
ready: true,
installed: true,
tty: false,
env: { CLAUDE_CODE_CHILD_SESSION: "1" },
};
expect(desktopHint(process.cwd(), agent)).toBe(
"Keep editing by chatting with Framey in the desktop app: hyperframes open .",
);
Expand Down
2 changes: 1 addition & 1 deletion scripts/package-size-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"unpacked": 290508
},
"@hyperframes/cli": {
"packed": 7982002,
"packed": 8062664,
"unpacked": 25159107
},
"@hyperframes/core": {
Expand Down
Loading