diff --git a/README.md b/README.md index 20ac70f..a81ba30 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,10 @@ Officially supported coding agents: | Name | ID | Session ID | | ----------------------------------------------------------------- | ------------- | ---------- | | [Antigravity](https://antigravity.google/) | `antigravity` | ✅ | +| [Augment CLI](https://www.augmentcode.com/product/cli) | `augment-cli` | 🚫 | | [Bolt](https://bolt.new/) | `bolt` | 🚫 | | [Claude Code](https://claude.com/claude-code) | `claude-code` | ✅ | +| [Claude Cowork](https://claude.com/product/cowork) | `cowork` | ✅ | | [Cline](https://cline.bot/) | `cline` | 🚫 | | [Codex](https://developers.openai.com/codex/) | `codex` | ✅ | | [Cursor](https://cursor.com/) | `cursor` | ✅ | @@ -49,12 +51,15 @@ Officially supported coding agents: | [Pi](https://github.com/badlogic/pi-mono) | `pi` | 🚫 | | [Replit](https://replit.com/) | `replit` | ✅ | | [Rork](https://rork.com/) | `rork` | 🚫 | +| [v0](https://v0.app/) | `v0` | 🚫 | -Detection is data-driven: the exact environment variables and process -patterns for each agent live in [`src/agents.ts`](src/agents.ts). +Agent-specific detection is data-driven: the environment variables, filesystem +markers, and process patterns live in [`src/agents.ts`](src/agents.ts). -OpenCode 1.0.19 and later is detected from environment variables by default. -OpenCode 0.x requires experimental process-tree detection. +The package recognizes `AI_AGENT` as an explicit, highest-priority agent name. +Known aliases such as `claude`, `cursor-cli`, `github-copilot`, and +`github-copilot-cli` are normalized to the stable IDs in the table. Other +non-empty values are returned verbatim as both the agent ID and name. ## API @@ -98,7 +103,7 @@ A string containing the agent's session identifier, normalized from agent-specific environment variables such as `CODEX_THREAD_ID`, `CURSOR_CONVERSATION_ID`, `CLAUDE_CODE_SESSION_ID`, `ANTIGRAVITY_TRAJECTORY_ID`, `KIRO_SESSION_ID`, `KILO_RUN_ID`, and -`COPILOT_AGENT_SESSION_ID`, and `REPLIT_SESSION`. Omitted when the agent doesn't expose one +`COPILOT_AGENT_SESSION_ID`, or `REPLIT_SESSION`. Omitted when the agent doesn't expose one (see the Session ID column in the support table above). ### `isRunningFromAgent([options])` @@ -129,7 +134,8 @@ Exit codes: ## Process-tree detection (experimental) -Detection uses environment variables by default. Parent-process +Detection uses environment variables and Devin's `/opt/.devin` marker by +default. Parent-process inspection is available as an opt-in for callers that can tolerate best-effort process-tree matching: @@ -154,7 +160,9 @@ const result = detectAgent({ { id: "my-agent", name: "My Agent", + aliases: ["my-agent-cli"], env: [{ name: "MY_AGENT", value: "1" }], + filesystem: [{ path: "/opt/my-agent" }], process: [{ pattern: /^my-agent$/i }] } ] diff --git a/src/agents.ts b/src/agents.ts index 0b17a3e..51a77d5 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -6,6 +6,7 @@ export const defaultAgents = [ name: "Codex", env: [ { name: "CODEX_CI", value: "1" }, + { name: "CODEX_SANDBOX" }, { name: "CODEX_SHELL", value: "1" }, { name: "CODEX_THREAD_ID" } ], @@ -15,18 +16,34 @@ export const defaultAgents = [ { id: "cursor", name: "Cursor", + aliases: ["cursor-cli"], env: [ { name: "CURSOR_AGENT", value: "1" }, - { name: "CURSOR_CONVERSATION_ID" } + { name: "CURSOR_CONVERSATION_ID" }, + { name: "CURSOR_EXTENSION_HOST_ROLE", value: "agent-exec" }, + { name: "CURSOR_TRACE_ID" } ], process: [{ pattern: /^cursor$/i }], sessionEnv: ["CURSOR_CONVERSATION_ID"] }, + { + id: "cowork", + name: "Claude Cowork", + env: [ + { + name: "CLAUDE_CODE_IS_COWORK", + condition: (env) => Boolean(env.CLAUDECODE || env.CLAUDE_CODE) + } + ], + sessionEnv: ["CLAUDE_CODE_SESSION_ID"] + }, { id: "claude-code", name: "Claude Code", + aliases: ["claude"], env: [ { name: "CLAUDECODE", value: "1" }, + { name: "CLAUDE_CODE" }, { name: "CLAUDE_CODE_SESSION_ID" } ], process: [{ pattern: /^claude$/i }], @@ -37,6 +54,7 @@ export const defaultAgents = [ name: "OpenCode", env: [ { name: "OPENCODE", value: "1" }, + { name: "OPENCODE_CLIENT" }, { name: "OPENCODE_PID" } ], process: [{ pattern: /^opencode$/i }] @@ -44,6 +62,7 @@ export const defaultAgents = [ { id: "devin", name: "Devin", + filesystem: [{ path: "/opt/.devin" }], process: [{ pattern: /devin/i }] }, { @@ -62,6 +81,11 @@ export const defaultAgents = [ process: [{ pattern: /^antigravity$/i }], sessionEnv: ["ANTIGRAVITY_TRAJECTORY_ID"] }, + { + id: "augment-cli", + name: "Augment CLI", + env: [{ name: "AUGMENT_AGENT" }] + }, { id: "bolt", name: "Bolt", @@ -114,12 +138,20 @@ export const defaultAgents = [ { id: "copilot", name: "GitHub Copilot CLI", + aliases: ["github-copilot", "github-copilot-cli"], env: [ { name: "COPILOT_CLI", value: "1" }, { name: "COPILOT_AGENT_SESSION_ID" }, - { name: "COPILOT_RUN_APP", value: "1" } + { name: "COPILOT_RUN_APP", value: "1" }, + { name: "COPILOT_MODEL" }, + { name: "COPILOT_ALLOW_ALL", value: "true" }, + { name: "COPILOT_GITHUB_TOKEN" } ], process: [{ pattern: /^(github-copilot-cli|copilot)$/i }], sessionEnv: ["COPILOT_AGENT_SESSION_ID"] + }, + { + id: "v0", + name: "v0" } ] satisfies readonly AgentDefinition[]; diff --git a/src/detector.ts b/src/detector.ts index 1727f3c..cfecc3c 100644 --- a/src/detector.ts +++ b/src/detector.ts @@ -1,5 +1,6 @@ import { defaultAgents } from "./agents.js"; import { EnvironmentDetectionStrategy } from "./strategies/environment.js"; +import { FileSystemDetectionStrategy } from "./strategies/filesystem.js"; import { ProcessTreeDetectionStrategy } from "./strategies/process-tree.js"; import type { AgentDefinition, @@ -14,8 +15,9 @@ import type { export function detectAgent(options: DetectAgentOptions = {}): DetectionResult { const agents = options.agents ?? defaultAgents; const strategies = options.strategies ?? defaultStrategies(options.experimentalProcessTree); + const env = options.env ?? process.env; const context: DetectionContext = { - env: options.env ?? process.env, + env, pid: options.pid ?? process.pid, agents, maxProcessDepth: options.maxProcessDepth ?? 20 @@ -45,7 +47,10 @@ export function createDefaultStrategies(experimentalProcessTree = false): readon } function defaultStrategies(experimentalProcessTree = false): readonly DetectionStrategy[] { - const strategies: DetectionStrategy[] = [new EnvironmentDetectionStrategy()]; + const strategies: DetectionStrategy[] = [ + new EnvironmentDetectionStrategy(), + new FileSystemDetectionStrategy() + ]; if (experimentalProcessTree) { strategies.push(new ProcessTreeDetectionStrategy()); @@ -59,6 +64,19 @@ function selectBestAgent( matches: readonly DetectionEvidence[], env: NodeJS.ProcessEnv ): DetectedAgent | undefined { + const explicitMatch = matches.find( + (match) => match.strategy === "environment" && match.signal === "AI_AGENT" + ); + + if (explicitMatch !== undefined) { + const definition = agents.find((agent) => agent.id === explicitMatch.agent.id); + + return { + ...explicitMatch.agent, + ...(definition === undefined ? {} : sessionIdProperties(definition, env)) + }; + } + const matchedAgentIds = new Set(matches.map((match) => match.agent.id)); for (const agent of agents) { diff --git a/src/index.ts b/src/index.ts index 2f24632..b3c70e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,11 @@ export { isRunningFromAgent } from "./detector.js"; export { EnvironmentDetectionStrategy } from "./strategies/environment.js"; +export { + FileSystemDetectionStrategy, + NodeFileSystemReader, + type FileSystemReader +} from "./strategies/filesystem.js"; export { ProcessTreeDetectionStrategy, PsProcessReader, @@ -23,6 +28,7 @@ export type { DetectionStrategyName, EnvSignal, EnvValueMatcher, + FileSystemSignal, ProcessInfo, ProcessSignal } from "./types.js"; diff --git a/src/strategies/environment.ts b/src/strategies/environment.ts index 3d1e104..4c1741e 100644 --- a/src/strategies/environment.ts +++ b/src/strategies/environment.ts @@ -12,12 +12,21 @@ export class EnvironmentDetectionStrategy implements DetectionStrategy { detect(context: DetectionContext): readonly DetectionEvidence[] { const matches: DetectionEvidence[] = []; + const explicitMatch = matchExplicitAgent(context); + + if (explicitMatch !== undefined) { + matches.push(explicitMatch); + } for (const agent of context.agents) { for (const signal of agent.env ?? []) { const value = context.env[signal.name]; - if (value === undefined || !matchesEnvValue(value, signal.value)) { + if ( + value === undefined || + !matchesEnvValue(value, signal.value) || + (signal.condition !== undefined && !signal.condition(context.env)) + ) { continue; } @@ -29,9 +38,31 @@ export class EnvironmentDetectionStrategy implements DetectionStrategy { } } +function matchExplicitAgent(context: DetectionContext): DetectionEvidence | undefined { + const name = context.env.AI_AGENT?.trim(); + + if (!name) { + return undefined; + } + + const knownAgent = context.agents.find( + (agent) => agent.id === name || agent.aliases?.includes(name) + ); + + return { + agent: + knownAgent === undefined + ? { id: name, name } + : { id: knownAgent.id, name: knownAgent.name }, + strategy: "environment", + signal: "AI_AGENT", + value: name + }; +} + function matchesEnvValue(value: string, matcher: EnvValueMatcher | undefined): boolean { if (matcher === undefined) { - return true; + return value.length > 0; } if (typeof matcher === "string") { diff --git a/src/strategies/filesystem.ts b/src/strategies/filesystem.ts new file mode 100644 index 0000000..937a063 --- /dev/null +++ b/src/strategies/filesystem.ts @@ -0,0 +1,60 @@ +import { existsSync } from "node:fs"; +import type { + AgentDefinition, + DetectionContext, + DetectionEvidence, + DetectionStrategy, + FileSystemSignal +} from "../types.js"; + +export interface FileSystemReader { + exists(path: string): boolean; +} + +export class NodeFileSystemReader implements FileSystemReader { + exists(path: string): boolean { + return existsSync(path); + } +} + +export class FileSystemDetectionStrategy implements DetectionStrategy { + readonly name = "filesystem"; + + constructor(private readonly reader: FileSystemReader = new NodeFileSystemReader()) {} + + detect(context: DetectionContext): readonly DetectionEvidence[] { + const matches: DetectionEvidence[] = []; + + for (const agent of context.agents) { + for (const signal of agent.filesystem ?? []) { + if (!this.pathExists(signal.path)) { + continue; + } + + matches.push(toEvidence(agent, signal)); + } + } + + return matches; + } + + private pathExists(path: string): boolean { + try { + return this.reader.exists(path); + } catch { + return false; + } + } +} + +function toEvidence(agent: AgentDefinition, signal: FileSystemSignal): DetectionEvidence { + return { + agent: { + id: agent.id, + name: agent.name + }, + strategy: "filesystem", + signal: signal.path, + value: signal.path + }; +} diff --git a/src/types.ts b/src/types.ts index 82a8582..9e51aa7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,21 +1,24 @@ export type AgentId = | "antigravity" + | "augment-cli" | "bolt" + | "claude-code" | "cline" | "codex" | "copilot" + | "cowork" | "cursor" | "devin" | "gemini" | "kiro" | "kilocode" - | "claude-code" | "opencode" | "pi" | "replit" - | "rork"; + | "rork" + | "v0"; -export type DetectionStrategyName = "environment" | "process-tree"; +export type DetectionStrategyName = "environment" | "filesystem" | "process-tree"; export type EnvValueMatcher = | string @@ -25,6 +28,12 @@ export type EnvValueMatcher = export interface EnvSignal { readonly name: string; readonly value?: EnvValueMatcher; + readonly condition?: (env: NodeJS.ProcessEnv) => boolean; + readonly description?: string; +} + +export interface FileSystemSignal { + readonly path: string; readonly description?: string; } @@ -43,7 +52,9 @@ export interface DetectedAgent extends AgentIdentity { } export interface AgentDefinition extends AgentIdentity { + readonly aliases?: readonly string[]; readonly env?: readonly EnvSignal[]; + readonly filesystem?: readonly FileSystemSignal[]; readonly process?: readonly ProcessSignal[]; readonly sessionEnv?: readonly string[]; } diff --git a/test/detector.test.mjs b/test/detector.test.mjs index 450f09b..a2bdfd3 100644 --- a/test/detector.test.mjs +++ b/test/detector.test.mjs @@ -4,9 +4,53 @@ import { createDefaultStrategies, detectAgent, EnvironmentDetectionStrategy, + FileSystemDetectionStrategy, ProcessTreeDetectionStrategy } from "../dist/index.js"; +test("AI_AGENT takes priority and supports custom agent names", () => { + const result = detectAgent({ + env: { + AI_AGENT: " my-custom-agent@1 ", + CODEX_CI: "1" + } + }); + + assert.deepEqual(result, { + detected: true, + agent: { + id: "my-custom-agent@1", + name: "my-custom-agent@1" + } + }); +}); + +test("AI_AGENT aliases normalize to stable package ids", () => { + for (const [value, id] of [ + ["claude", "claude-code"], + ["cursor-cli", "cursor"], + ["github-copilot", "copilot"], + ["github-copilot-cli", "copilot"] + ]) { + const result = detectAgent({ env: { AI_AGENT: value } }); + + assert.equal(result.detected, true); + assert.equal(result.agent.id, id); + } +}); + +test("ignores an empty AI_AGENT override", () => { + const result = detectAgent({ + env: { + AI_AGENT: " ", + CODEX_CI: "1" + } + }); + + assert.equal(result.detected, true); + assert.equal(result.agent.id, "codex"); +}); + test("detects an agent from a strong environment variable", () => { const result = detectAgent({ env: { @@ -78,6 +122,64 @@ test("detects rork from RORK_API_URL", () => { }); }); +test("detects the additional agent environment signals", () => { + for (const [env, id] of [ + [{ CODEX_SANDBOX: "seatbelt" }, "codex"], + [{ CURSOR_TRACE_ID: "trace-123" }, "cursor"], + [{ CURSOR_EXTENSION_HOST_ROLE: "agent-exec" }, "cursor"], + [{ CLAUDE_CODE: "1" }, "claude-code"], + [{ OPENCODE_CLIENT: "opencode" }, "opencode"], + [{ AUGMENT_AGENT: "1" }, "augment-cli"], + [{ COPILOT_MODEL: "gpt-5" }, "copilot"], + [{ COPILOT_ALLOW_ALL: "true" }, "copilot"], + [{ COPILOT_GITHUB_TOKEN: "token" }, "copilot"] + ]) { + const result = detectAgent({ env }); + + assert.equal(result.detected, true); + assert.equal(result.agent.id, id); + } +}); + +test("distinguishes Claude Cowork from Claude Code", () => { + const result = detectAgent({ + env: { + CLAUDE_CODE: "1", + CLAUDE_CODE_IS_COWORK: "1", + CLAUDE_CODE_SESSION_ID: "session-123" + } + }); + + assert.deepEqual(result, { + detected: true, + agent: { + id: "cowork", + name: "Claude Cowork", + sessionId: "session-123" + } + }); +}); + +test("does not detect Cowork from its discriminator alone", () => { + const result = detectAgent({ + env: { + CLAUDE_CODE_IS_COWORK: "1" + } + }); + + assert.equal(result.detected, false); +}); + +test("detects v0 through the AI_AGENT standard", () => { + const result = detectAgent({ env: { AI_AGENT: "v0" } }); + + assert.equal(result.detected, true); + assert.deepEqual(result.agent, { + id: "v0", + name: "v0" + }); +}); + test("detects bolt from any known environment variable", () => { for (const envName of ["BOLT_ENV", "BOLT_ORIGIN", "BOLT_SERVER_URL"]) { const result = detectAgent({ @@ -106,20 +208,43 @@ test("detects opencode from any known environment variable", () => { } }); -test("default strategies only use environment variables", () => { +test("default strategies use environment and filesystem signals", () => { assert.deepEqual( createDefaultStrategies().map((strategy) => strategy.name), - ["environment"] + ["environment", "filesystem"] ); }); test("process tree strategy is experimental and opt-in", () => { assert.deepEqual( createDefaultStrategies(true).map((strategy) => strategy.name), - ["environment", "process-tree"] + ["environment", "filesystem", "process-tree"] ); }); +test("filesystem detection can detect Devin's local marker", () => { + const strategy = new FileSystemDetectionStrategy({ + exists(path) { + return path === "/opt/.devin"; + } + }); + const result = detectAgent({ env: {}, strategies: [strategy] }); + + assert.equal(result.detected, true); + assert.equal(result.agent.id, "devin"); +}); + +test("filesystem detection treats reader errors as a miss", () => { + const strategy = new FileSystemDetectionStrategy({ + exists() { + throw new Error("permission denied"); + } + }); + const result = detectAgent({ env: {}, strategies: [strategy] }); + + assert.equal(result.detected, false); +}); + test("process tree detection can detect kiro in the hierarchy", () => { const processes = new Map([ [62926, { pid: 62926, ppid: 53313, command: "sh" }], @@ -149,13 +274,38 @@ test("does not detect unrelated environment variables", () => { const result = detectAgent({ env: { PATH: "/usr/bin" - } + }, + strategies: [new EnvironmentDetectionStrategy()] }); assert.equal(result.detected, false); assert.equal(result.agent, undefined); }); +test("does not treat empty signal values as agent evidence", () => { + const result = detectAgent({ + env: { + CODEX_SANDBOX: "", + CURSOR_TRACE_ID: "", + OPENCODE_CLIENT: "" + }, + strategies: [new EnvironmentDetectionStrategy()] + }); + + assert.equal(result.detected, false); +}); + +test("does not treat a generic Replit workspace as an agent session", () => { + const result = detectAgent({ + env: { + REPL_ID: "application-uuid" + }, + strategies: [new EnvironmentDetectionStrategy()] + }); + + assert.equal(result.detected, false); +}); + test("process tree detection can detect opencode without env vars", () => { const processes = new Map([ [100, { pid: 100, ppid: 90, command: "node" }],