From ab31eee97aad648aad8be9934a666dbfbe570a30 Mon Sep 17 00:00:00 2001 From: melkeydev Date: Mon, 10 Aug 2026 13:20:20 -0700 Subject: [PATCH 1/8] adding plugin telemetry --- README.md | 11 ++- hooks/session-start-profiler-platform.test.ts | 27 +++++- hooks/session-start-profiler.mjs | 30 ++++++- hooks/src/session-start-profiler.mts | 43 +++++++++- hooks/src/telemetry.mts | 83 ++++++++++++++++--- hooks/telemetry.mjs | 57 ++++++++++--- tests/telemetry.test.ts | 40 ++++++++- 7 files changed, 257 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 8b76471d..b3dc6218 100644 --- a/README.md +++ b/README.md @@ -132,15 +132,19 @@ Each telemetry event contains only: - `id`: a random event UUID. - `event_time`: the event timestamp. - `key`: one of the event names listed above. -- `value`: currently `"1"`. +- `value`: `"1"` for counters or the plugin version for `plugin:version`. The request also sends HTTP headers used by the telemetry bridge: - `x-vercel-plugin-topic-id: dau` - `x-vercel-plugin-session-id`: a random UUID generated for that telemetry request. - `x-vercel-plugin-version`: the plugin version embedded at build time. +- `x-vercel-plugin-installation-id`: the locally stored random installation UUID. +- `x-vercel-plugin-agent-harness`: the detected agent harness or `unknown`. -Prompt text, bash commands, tool-call contents, file paths, project names, account IDs, and skill-injection details are not collected. +The installation ID is generated on the first telemetry-enabled plugin session and reused for that local installation. It is not derived from device, account, project, or user information. The harness value identifies agents such as Claude Code, Cursor, Codex, or GitHub Copilot; ambiguous harnesses are reported as `unknown`. + +Prompt text, bash commands, tool-call contents, file paths, project names, account IDs, harness versions, and skill-injection details are not collected. How it is tracked: @@ -149,13 +153,14 @@ How it is tracked: - Local throttle files are stored under `~/.config/vercel-plugin/`: - `dau-stamp` prevents sending `dau:active_today` more than once per UTC day. - `first-use-stamp` prevents sending `plugin:first_use` more than once. + - `installation-id` stores the random installation UUID. It is used only by plugin telemetry and is not written to `active-session.json`. - Stamp files are written only after the telemetry bridge returns a successful response, so failed sends can retry later. - `active-session.json` is refreshed on session start with the plugin version and expiry timestamp. It lets Vercel CLI telemetry identify commands run while a recent Vercel plugin session marker is present. It contains no prompt text, file paths, project names, account IDs, tool-call contents, or skill-injection details. Behavior: - Unset `VERCEL_PLUGIN_TELEMETRY`: telemetry is enabled. -- `VERCEL_PLUGIN_TELEMETRY=off`: disables all telemetry, including `dau:active_today` and `plugin:first_use`. +- `VERCEL_PLUGIN_TELEMETRY=off`: disables all telemetry, including `dau:active_today` and `plugin:first_use`, and does not create an installation ID if one does not already exist. Where to set `VERCEL_PLUGIN_TELEMETRY`: diff --git a/hooks/session-start-profiler-platform.test.ts b/hooks/session-start-profiler-platform.test.ts index 7521d16d..20a2e351 100644 --- a/hooks/session-start-profiler-platform.test.ts +++ b/hooks/session-start-profiler-platform.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { detectSessionStartPlatform } from "./src/session-start-profiler.mts"; +import { + detectAgentHarness, + detectSessionStartPlatform, +} from "./src/session-start-profiler.mts"; describe("session-start-profiler platform detection", () => { test("test_session_start_profiler_does_not_infer_cursor_from_cursor_project_dir_alone", () => { @@ -25,4 +28,26 @@ describe("session-start-profiler platform detection", () => { ), ).toBe("claude-code"); }); + + test("detects supported agent harnesses from explicit signals", () => { + expect(detectAgentHarness({ cursor_version: "1.0.0" }, {})).toBe("cursor"); + expect(detectAgentHarness({}, { COPILOT_PLUGIN_DATA: "/tmp/copilot-data" })).toBe( + "github-copilot", + ); + expect( + detectAgentHarness({}, { + PLUGIN_DATA: "/tmp/codex-data", + CLAUDE_PLUGIN_DATA: "/tmp/compat-data", + }), + ).toBe("codex"); + expect(detectAgentHarness({}, { CLAUDE_ENV_FILE: "/tmp/claude.env" })).toBe( + "claude-code", + ); + }); + + test("returns unknown instead of guessing from ambiguous environment state", () => { + expect(detectAgentHarness({}, {})).toBe("unknown"); + expect(detectAgentHarness({}, { CODEX_HOME: "/tmp/codex" })).toBe("unknown"); + expect(detectAgentHarness({}, { CLAUDE_PLUGIN_ROOT: "/tmp/plugin" })).toBe("unknown"); + }); }); diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index ef3ec681..e9b02e72 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -18,7 +18,10 @@ import { pluginRoot, safeReadJson, writeSessionFile } from "./hook-env.mjs"; import { createLogger, logCaughtError } from "./logger.mjs"; import { hasSessionStartActivationMarkers } from "./session-start-activation.mjs"; import { buildSkillMap } from "./skill-map-frontmatter.mjs"; -import { refreshActiveSessionMarker, trackDauActiveToday } from "./telemetry.mjs"; +import { + refreshActiveSessionMarker, + trackDauActiveToday +} from "./telemetry.mjs"; var FILE_MARKERS = [ { file: ".eve", skills: ["eve"] }, { file: "next.config.js", skills: ["nextjs", "turbopack"] }, @@ -322,6 +325,25 @@ function detectSessionStartPlatform(input, env = process.env) { } return "claude-code"; } +function hasNonEmptyEnv(env, key) { + const value = env[key]; + return typeof value === "string" && value.trim() !== ""; +} +function detectAgentHarness(input, env = process.env) { + if (input && ("conversation_id" in input || "cursor_version" in input)) { + return "cursor"; + } + if (hasNonEmptyEnv(env, "COPILOT_PLUGIN_DATA")) { + return "github-copilot"; + } + if (hasNonEmptyEnv(env, "PLUGIN_DATA") || hasNonEmptyEnv(env, "PLUGIN_ROOT")) { + return "codex"; + } + if (hasNonEmptyEnv(env, "CLAUDE_ENV_FILE")) { + return "claude-code"; + } + return "unknown"; +} function normalizeSessionStartSessionId(input) { if (!input) return null; const sessionId = normalizeInput(input).sessionId; @@ -415,6 +437,7 @@ function formatSessionStartProfilerCursorOutput(envVars, userMessages) { async function main() { const hookInput = parseSessionStartInput(readFileSync(0, "utf8")); const platform = detectSessionStartPlatform(hookInput); + const agentHarness = detectAgentHarness(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); const projectRoot = resolveSessionStartProjectRoot(); refreshActiveSessionMarker(); @@ -432,7 +455,7 @@ async function main() { if (platform === "cursor") { process.stdout.write(JSON.stringify(formatOutput("cursor", {}))); } - await trackDauActiveToday().catch(() => { + await trackDauActiveToday(/* @__PURE__ */ new Date(), { agentHarness }).catch(() => { }); process.exit(0); } @@ -475,7 +498,7 @@ async function main() { `); } - await trackDauActiveToday().catch(() => { + await trackDauActiveToday(/* @__PURE__ */ new Date(), { agentHarness }).catch(() => { }); if (cursorOutput) { process.stdout.write(cursorOutput); @@ -491,6 +514,7 @@ export { buildSessionStartProfilerEnvVars, buildSessionStartProfilerUserMessages, checkGreenfield, + detectAgentHarness, detectSessionStartPlatform, formatSessionStartProfilerCursorOutput, logBrokenSkillFrontmatterSummary, diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index be81b10d..849a53e5 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -32,7 +32,11 @@ import { pluginRoot, safeReadJson, writeSessionFile } from "./hook-env.mjs"; import { createLogger, logCaughtError, type Logger } from "./logger.mjs"; import { hasSessionStartActivationMarkers } from "./session-start-activation.mjs"; import { buildSkillMap } from "./skill-map-frontmatter.mjs"; -import { refreshActiveSessionMarker, trackDauActiveToday } from "./telemetry.mjs"; +import { + refreshActiveSessionMarker, + trackDauActiveToday, + type AgentHarness, +} from "./telemetry.mjs"; // --------------------------------------------------------------------------- // Types @@ -502,6 +506,38 @@ export function detectSessionStartPlatform( return "claude-code"; } +function hasNonEmptyEnv(env: NodeJS.ProcessEnv, key: string): boolean { + const value = env[key]; + return typeof value === "string" && value.trim() !== ""; +} + +/** + * Detect only documented, explicit harness signals. Ambiguous sessions remain + * unknown rather than being inferred from installed binaries or local files. + */ +export function detectAgentHarness( + input: SessionStartInput | null, + env: NodeJS.ProcessEnv = process.env, +): AgentHarness { + if (input && ("conversation_id" in input || "cursor_version" in input)) { + return "cursor"; + } + + if (hasNonEmptyEnv(env, "COPILOT_PLUGIN_DATA")) { + return "github-copilot"; + } + + if (hasNonEmptyEnv(env, "PLUGIN_DATA") || hasNonEmptyEnv(env, "PLUGIN_ROOT")) { + return "codex"; + } + + if (hasNonEmptyEnv(env, "CLAUDE_ENV_FILE")) { + return "claude-code"; + } + + return "unknown"; +} + export function normalizeSessionStartSessionId(input: SessionStartInput | null): string | null { if (!input) return null; @@ -626,6 +662,7 @@ export function formatSessionStartProfilerCursorOutput( async function main(): Promise { const hookInput = parseSessionStartInput(readFileSync(0, "utf8")); const platform = detectSessionStartPlatform(hookInput); + const agentHarness = detectAgentHarness(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); const projectRoot = resolveSessionStartProjectRoot(); refreshActiveSessionMarker(); @@ -649,7 +686,7 @@ async function main(): Promise { process.stdout.write(JSON.stringify(formatOutput("cursor", {}))); } - await trackDauActiveToday().catch(() => {}); + await trackDauActiveToday(new Date(), { agentHarness }).catch(() => {}); process.exit(0); } @@ -706,7 +743,7 @@ async function main(): Promise { } // DAU phone-home — enabled by default unless VERCEL_PLUGIN_TELEMETRY=off - await trackDauActiveToday().catch(() => {}); + await trackDauActiveToday(new Date(), { agentHarness }).catch(() => {}); if (cursorOutput) { process.stdout.write(cursorOutput); diff --git a/hooks/src/telemetry.mts b/hooks/src/telemetry.mts index c5414b17..15dfa18d 100644 --- a/hooks/src/telemetry.mts +++ b/hooks/src/telemetry.mts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; @@ -12,8 +12,24 @@ const ACTIVE_SESSION_TTL_MS = 60 * 60 * 1000; const DAU_STAMP_PATH = join(homedir(), ".config", "vercel-plugin", "dau-stamp"); const FIRST_USE_STAMP_PATH = join(homedir(), ".config", "vercel-plugin", "first-use-stamp"); +const INSTALLATION_ID_PATH = join(homedir(), ".config", "vercel-plugin", "installation-id"); const ACTIVE_SESSION_MARKER_PATH = join(homedir(), ".config", "vercel-plugin", "active-session.json"); +const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type AgentHarness = + | "claude-code" + | "cursor" + | "codex" + | "github-copilot" + | "kimi" + | "grok" + | "unknown"; + +export interface TelemetryContext { + agentHarness?: AgentHarness; +} + export interface TelemetryEvent { id: string; event_time: number; @@ -29,20 +45,30 @@ export interface ActiveSessionMarker { expiresAt: number; } -async function sendTelemetry(events: TelemetryEvent[]): Promise { +async function sendTelemetry( + events: TelemetryEvent[], + installationId: string | null, + agentHarness: AgentHarness, +): Promise { if (events.length === 0) return false; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS); try { + const headers: Record = { + "Content-Type": "application/json", + "x-vercel-plugin-topic-id": "dau", + "x-vercel-plugin-session-id": randomUUID(), + "x-vercel-plugin-version": PLUGIN_VERSION, + "x-vercel-plugin-agent-harness": agentHarness, + }; + if (installationId) { + headers["x-vercel-plugin-installation-id"] = installationId; + } + const response = await fetch(BRIDGE_ENDPOINT, { method: "POST", - headers: { - "Content-Type": "application/json", - "x-vercel-plugin-topic-id": "dau", - "x-vercel-plugin-session-id": randomUUID(), - "x-vercel-plugin-version": PLUGIN_VERSION, - }, + headers, body: JSON.stringify(events), signal: controller.signal, }); @@ -66,10 +92,42 @@ export function getFirstUseStampPath(): string { return FIRST_USE_STAMP_PATH; } +export function getInstallationIdPath(): string { + return INSTALLATION_ID_PATH; +} + export function getActiveSessionMarkerPath(): string { return ACTIVE_SESSION_MARKER_PATH; } +function readInstallationId(): string | null { + try { + const value = readFileSync(INSTALLATION_ID_PATH, "utf8").trim(); + return UUID_V4_RE.test(value) ? value : null; + } catch { + return null; + } +} + +function getOrCreateInstallationId(): string | null { + const existing = readInstallationId(); + if (existing) return existing; + + try { + mkdirSync(dirname(INSTALLATION_ID_PATH), { recursive: true, mode: 0o700 }); + const installationId = randomUUID(); + writeFileSync(INSTALLATION_ID_PATH, `${installationId}\n`, { + flag: "wx", + mode: 0o600, + }); + return installationId; + } catch { + // Another process may have created the file first. Never send an + // ephemeral identifier when a stable value cannot be read from disk. + return readInstallationId(); + } +} + function utcDayStamp(date: Date): string { return date.toISOString().slice(0, 10); } @@ -164,9 +222,14 @@ export function refreshActiveSessionMarker(now: Date = new Date()): void { // DAU telemetry (default-on, opt-out via VERCEL_PLUGIN_TELEMETRY=off) // --------------------------------------------------------------------------- -export async function trackDauActiveToday(now: Date = new Date()): Promise { +export async function trackDauActiveToday( + now: Date = new Date(), + context: TelemetryContext = {}, +): Promise { if (!isDauTelemetryEnabled()) return; + const installationId = getOrCreateInstallationId(); + const agentHarness = context.agentHarness ?? "unknown"; const eventTime = now.getTime(); const events: TelemetryEvent[] = []; @@ -197,7 +260,7 @@ export async function trackDauActiveToday(now: Date = new Date()): Promise }); } - const sent = await sendTelemetry(events); + const sent = await sendTelemetry(events, installationId, agentHarness); if (sent) { for (const event of events) { diff --git a/hooks/telemetry.mjs b/hooks/telemetry.mjs index 4dc6030d..7e522166 100644 --- a/hooks/telemetry.mjs +++ b/hooks/telemetry.mjs @@ -1,6 +1,6 @@ // hooks/src/telemetry.mts import { randomUUID } from "crypto"; -import { mkdirSync, rmSync, statSync, writeFileSync } from "fs"; +import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; import { join, dirname } from "path"; import { homedir } from "os"; var BRIDGE_ENDPOINT = "https://telemetry.vercel.com/api/vercel-plugin/v1/events"; @@ -9,20 +9,27 @@ var PLUGIN_VERSION = true ? "0.47.0" : "0.43.0"; var ACTIVE_SESSION_TTL_MS = 60 * 60 * 1e3; var DAU_STAMP_PATH = join(homedir(), ".config", "vercel-plugin", "dau-stamp"); var FIRST_USE_STAMP_PATH = join(homedir(), ".config", "vercel-plugin", "first-use-stamp"); +var INSTALLATION_ID_PATH = join(homedir(), ".config", "vercel-plugin", "installation-id"); var ACTIVE_SESSION_MARKER_PATH = join(homedir(), ".config", "vercel-plugin", "active-session.json"); -async function sendTelemetry(events) { +var UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +async function sendTelemetry(events, installationId, agentHarness) { if (events.length === 0) return false; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS); try { + const headers = { + "Content-Type": "application/json", + "x-vercel-plugin-topic-id": "dau", + "x-vercel-plugin-session-id": randomUUID(), + "x-vercel-plugin-version": PLUGIN_VERSION, + "x-vercel-plugin-agent-harness": agentHarness + }; + if (installationId) { + headers["x-vercel-plugin-installation-id"] = installationId; + } const response = await fetch(BRIDGE_ENDPOINT, { method: "POST", - headers: { - "Content-Type": "application/json", - "x-vercel-plugin-topic-id": "dau", - "x-vercel-plugin-session-id": randomUUID(), - "x-vercel-plugin-version": PLUGIN_VERSION - }, + headers, body: JSON.stringify(events), signal: controller.signal }); @@ -39,9 +46,36 @@ function getDauStampPath() { function getFirstUseStampPath() { return FIRST_USE_STAMP_PATH; } +function getInstallationIdPath() { + return INSTALLATION_ID_PATH; +} function getActiveSessionMarkerPath() { return ACTIVE_SESSION_MARKER_PATH; } +function readInstallationId() { + try { + const value = readFileSync(INSTALLATION_ID_PATH, "utf8").trim(); + return UUID_V4_RE.test(value) ? value : null; + } catch { + return null; + } +} +function getOrCreateInstallationId() { + const existing = readInstallationId(); + if (existing) return existing; + try { + mkdirSync(dirname(INSTALLATION_ID_PATH), { recursive: true, mode: 448 }); + const installationId = randomUUID(); + writeFileSync(INSTALLATION_ID_PATH, `${installationId} +`, { + flag: "wx", + mode: 384 + }); + return installationId; + } catch { + return readInstallationId(); + } +} function utcDayStamp(date) { return date.toISOString().slice(0, 10); } @@ -110,8 +144,10 @@ function refreshActiveSessionMarker(now = /* @__PURE__ */ new Date()) { } catch { } } -async function trackDauActiveToday(now = /* @__PURE__ */ new Date()) { +async function trackDauActiveToday(now = /* @__PURE__ */ new Date(), context = {}) { if (!isDauTelemetryEnabled()) return; + const installationId = getOrCreateInstallationId(); + const agentHarness = context.agentHarness ?? "unknown"; const eventTime = now.getTime(); const events = []; if (shouldSendDauPing(now)) { @@ -138,7 +174,7 @@ async function trackDauActiveToday(now = /* @__PURE__ */ new Date()) { value: PLUGIN_VERSION }); } - const sent = await sendTelemetry(events); + const sent = await sendTelemetry(events, installationId, agentHarness); if (sent) { for (const event of events) { if (event.key === "dau:active_today") markDauPingSent(now); @@ -151,6 +187,7 @@ export { getActiveSessionMarkerPath, getDauStampPath, getFirstUseStampPath, + getInstallationIdPath, getTelemetryOverride, isDauTelemetryEnabled, markDauPingSent, diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index a4bcb8a5..7a04ac8b 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -11,14 +11,18 @@ let tempHome: string; async function runTelemetryProbe(options: { telemetryEnv?: string; + agentHarness?: string; }): Promise<{ dauEnabled: boolean; calls: number; stampPath: string; firstUseStampPath: string; + installationIdPath: string; + installationId: string | null; activeSessionMarkerPath: string; activeSessionMarker: unknown; dauPayloads: unknown[]; + dauHeaders: Array>; }> { const mergedEnv: Record = { ...(process.env as Record), @@ -36,18 +40,27 @@ async function runTelemetryProbe(options: { let calls = 0; const dauPayloads = []; + const dauHeaders = []; globalThis.fetch = async (_url, init) => { calls += 1; dauPayloads.push(JSON.parse(init.body)); + dauHeaders.push(Object.fromEntries(new Headers(init.headers).entries())); return new Response(null, { status: 204 }); }; const dauEnabled = telemetry.isDauTelemetryEnabled(); - await telemetry.trackDauActiveToday(); - await telemetry.trackDauActiveToday(); + const context = { agentHarness: ${JSON.stringify(options.agentHarness ?? "unknown")} }; + await telemetry.trackDauActiveToday(undefined, context); + await telemetry.trackDauActiveToday(undefined, context); const stampPath = telemetry.getDauStampPath(); const firstUseStampPath = telemetry.getFirstUseStampPath(); + const installationIdPath = telemetry.getInstallationIdPath(); + const installationId = await import("node:fs").then((fs) => + fs.existsSync(installationIdPath) + ? fs.readFileSync(installationIdPath, "utf-8").trim() + : null + ); const activeSessionMarkerPath = telemetry.getActiveSessionMarkerPath(); telemetry.refreshActiveSessionMarker(new Date("2026-05-15T12:00:00.000Z")); const activeSessionMarker = await import("node:fs").then((fs) => @@ -55,7 +68,7 @@ async function runTelemetryProbe(options: { ? JSON.parse(fs.readFileSync(activeSessionMarkerPath, "utf-8")) : null ); - console.log(JSON.stringify({ dauEnabled, calls, stampPath, firstUseStampPath, activeSessionMarkerPath, activeSessionMarker, dauPayloads })); + console.log(JSON.stringify({ dauEnabled, calls, stampPath, firstUseStampPath, installationIdPath, installationId, activeSessionMarkerPath, activeSessionMarker, dauPayloads, dauHeaders })); `; const proc = Bun.spawn([NODE_BIN, "--input-type=module", "-e", script], { @@ -77,9 +90,12 @@ async function runTelemetryProbe(options: { calls: number; stampPath: string; firstUseStampPath: string; + installationIdPath: string; + installationId: string | null; activeSessionMarkerPath: string; activeSessionMarker: unknown; dauPayloads: unknown[]; + dauHeaders: Array>; }; } @@ -98,19 +114,26 @@ describe("telemetry controls", () => { expect(result.calls).toBe(0); expect(existsSync(result.stampPath)).toBe(false); expect(existsSync(result.firstUseStampPath)).toBe(false); + expect(existsSync(result.installationIdPath)).toBe(false); + expect(result.installationId).toBeNull(); expect(existsSync(result.activeSessionMarkerPath)).toBe(false); expect(result.activeSessionMarker).toBeNull(); }); test("default telemetry sends DAU and first-use once", async () => { - const result = await runTelemetryProbe({}); + const result = await runTelemetryProbe({ agentHarness: "codex" }); expect(result.dauEnabled).toBe(true); expect(result.calls).toBe(1); expect(result.stampPath).toBe(join(tempHome, ".config", "vercel-plugin", "dau-stamp")); expect(result.firstUseStampPath).toBe(join(tempHome, ".config", "vercel-plugin", "first-use-stamp")); + expect(result.installationIdPath).toBe(join(tempHome, ".config", "vercel-plugin", "installation-id")); expect(result.activeSessionMarkerPath).toBe(join(tempHome, ".config", "vercel-plugin", "active-session.json")); expect(existsSync(result.stampPath)).toBe(true); expect(existsSync(result.firstUseStampPath)).toBe(true); + expect(existsSync(result.installationIdPath)).toBe(true); + expect(result.installationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); expect(existsSync(result.activeSessionMarkerPath)).toBe(true); expect(result.activeSessionMarker).toEqual({ schema: 1, @@ -135,6 +158,15 @@ describe("telemetry controls", () => { }), ], ]); + expect(result.dauHeaders).toHaveLength(1); + expect(result.dauHeaders[0]["x-vercel-plugin-installation-id"]).toBe( + result.installationId, + ); + expect(result.dauHeaders[0]["x-vercel-plugin-agent-harness"]).toBe("codex"); + + const repeated = await runTelemetryProbe({ agentHarness: "codex" }); + expect(repeated.installationId).toBe(result.installationId); + expect(repeated.calls).toBe(0); }); test("compiled hooks do not emit prompt, tool, or skill-injection telemetry keys", () => { From 7033da9a6a42db1008e8b82996d86c85c2ff9869 Mon Sep 17 00:00:00 2001 From: melkeydev Date: Mon, 10 Aug 2026 22:31:46 -0700 Subject: [PATCH 2/8] adding kimi and grok detection logic --- README.md | 2 +- hooks/session-start-profiler-platform.test.ts | 10 ++++++++++ hooks/session-start-profiler.mjs | 6 ++++++ hooks/src/session-start-profiler.mts | 8 ++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b3dc6218..befb231a 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ The request also sends HTTP headers used by the telemetry bridge: - `x-vercel-plugin-installation-id`: the locally stored random installation UUID. - `x-vercel-plugin-agent-harness`: the detected agent harness or `unknown`. -The installation ID is generated on the first telemetry-enabled plugin session and reused for that local installation. It is not derived from device, account, project, or user information. The harness value identifies agents such as Claude Code, Cursor, Codex, or GitHub Copilot; ambiguous harnesses are reported as `unknown`. +The installation ID is generated on the first telemetry-enabled plugin session and reused for that local installation. It is not derived from device, account, project, or user information. The harness value identifies Claude Code, Cursor, Codex, GitHub Copilot, Kimi Code, or Grok; ambiguous harnesses are reported as `unknown`. Prompt text, bash commands, tool-call contents, file paths, project names, account IDs, harness versions, and skill-injection details are not collected. diff --git a/hooks/session-start-profiler-platform.test.ts b/hooks/session-start-profiler-platform.test.ts index 20a2e351..f8c837d8 100644 --- a/hooks/session-start-profiler-platform.test.ts +++ b/hooks/session-start-profiler-platform.test.ts @@ -34,6 +34,15 @@ describe("session-start-profiler platform detection", () => { expect(detectAgentHarness({}, { COPILOT_PLUGIN_DATA: "/tmp/copilot-data" })).toBe( "github-copilot", ); + expect(detectAgentHarness({}, { KIMI_PLUGIN_ROOT: "/tmp/kimi-plugin" })).toBe( + "kimi", + ); + expect( + detectAgentHarness({}, { + GROK_PLUGIN_DATA: "/tmp/grok-data", + PLUGIN_DATA: "/tmp/compat-data", + }), + ).toBe("grok"); expect( detectAgentHarness({}, { PLUGIN_DATA: "/tmp/codex-data", @@ -48,6 +57,7 @@ describe("session-start-profiler platform detection", () => { test("returns unknown instead of guessing from ambiguous environment state", () => { expect(detectAgentHarness({}, {})).toBe("unknown"); expect(detectAgentHarness({}, { CODEX_HOME: "/tmp/codex" })).toBe("unknown"); + expect(detectAgentHarness({}, { KIMI_CODE_HOME: "/tmp/kimi" })).toBe("unknown"); expect(detectAgentHarness({}, { CLAUDE_PLUGIN_ROOT: "/tmp/plugin" })).toBe("unknown"); }); }); diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index e9b02e72..15115eb0 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -336,6 +336,12 @@ function detectAgentHarness(input, env = process.env) { if (hasNonEmptyEnv(env, "COPILOT_PLUGIN_DATA")) { return "github-copilot"; } + if (hasNonEmptyEnv(env, "KIMI_PLUGIN_ROOT")) { + return "kimi"; + } + if (hasNonEmptyEnv(env, "GROK_PLUGIN_ROOT") || hasNonEmptyEnv(env, "GROK_PLUGIN_DATA")) { + return "grok"; + } if (hasNonEmptyEnv(env, "PLUGIN_DATA") || hasNonEmptyEnv(env, "PLUGIN_ROOT")) { return "codex"; } diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index 849a53e5..6eb25379 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -527,6 +527,14 @@ export function detectAgentHarness( return "github-copilot"; } + if (hasNonEmptyEnv(env, "KIMI_PLUGIN_ROOT")) { + return "kimi"; + } + + if (hasNonEmptyEnv(env, "GROK_PLUGIN_ROOT") || hasNonEmptyEnv(env, "GROK_PLUGIN_DATA")) { + return "grok"; + } + if (hasNonEmptyEnv(env, "PLUGIN_DATA") || hasNonEmptyEnv(env, "PLUGIN_ROOT")) { return "codex"; } From 7ad286bfdb934f94571309b5f996a16a5e074397 Mon Sep 17 00:00:00 2001 From: melkeydev Date: Mon, 10 Aug 2026 23:03:29 -0700 Subject: [PATCH 3/8] upticking version --- .claude-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- .kimi-plugin/plugin.json | 2 +- .plugin/plugin.json | 2 +- hooks/src/telemetry.mts | 2 +- hooks/telemetry.mjs | 2 +- package.json | 2 +- tests/telemetry.test.ts | 4 ++-- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 66e631bc..1afba99e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vercel", - "version": "0.47.0", + "version": "0.48.0", "description": "Build and deploy web apps and agents", "author": { "name": "Vercel", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index b75a49e5..7919d841 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vercel", - "version": "0.47.0", + "version": "0.48.0", "description": "Build and deploy web apps and agents", "author": { "name": "Vercel", diff --git a/.kimi-plugin/plugin.json b/.kimi-plugin/plugin.json index baad5381..e8656d0e 100644 --- a/.kimi-plugin/plugin.json +++ b/.kimi-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vercel-plugin", - "version": "0.47.0", + "version": "0.48.0", "description": "Comprehensive Vercel ecosystem plugin — relational knowledge graph, skills for every major product, specialized agents, and Vercel conventions. Turns any AI agent into a Vercel expert.", "keywords": [ "vercel", diff --git a/.plugin/plugin.json b/.plugin/plugin.json index c8e953c8..6b2eeb59 100644 --- a/.plugin/plugin.json +++ b/.plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vercel-plugin", - "version": "0.47.0", + "version": "0.48.0", "description": "Comprehensive Vercel ecosystem plugin — relational knowledge graph, skills for every major product, specialized agents, and Vercel conventions. Turns any AI agent into a Vercel expert.", "author": { "name": "Vercel", diff --git a/hooks/src/telemetry.mts b/hooks/src/telemetry.mts index 15dfa18d..66328c88 100644 --- a/hooks/src/telemetry.mts +++ b/hooks/src/telemetry.mts @@ -7,7 +7,7 @@ declare const __VERCEL_PLUGIN_VERSION__: string; const BRIDGE_ENDPOINT = "https://telemetry.vercel.com/api/vercel-plugin/v1/events"; const FLUSH_TIMEOUT_MS = 3_000; -export const PLUGIN_VERSION = typeof __VERCEL_PLUGIN_VERSION__ === "string" ? __VERCEL_PLUGIN_VERSION__ : "0.43.0"; +export const PLUGIN_VERSION = typeof __VERCEL_PLUGIN_VERSION__ === "string" ? __VERCEL_PLUGIN_VERSION__ : "0.48.0"; const ACTIVE_SESSION_TTL_MS = 60 * 60 * 1000; const DAU_STAMP_PATH = join(homedir(), ".config", "vercel-plugin", "dau-stamp"); diff --git a/hooks/telemetry.mjs b/hooks/telemetry.mjs index 7e522166..ab51f3fd 100644 --- a/hooks/telemetry.mjs +++ b/hooks/telemetry.mjs @@ -5,7 +5,7 @@ import { join, dirname } from "path"; import { homedir } from "os"; var BRIDGE_ENDPOINT = "https://telemetry.vercel.com/api/vercel-plugin/v1/events"; var FLUSH_TIMEOUT_MS = 3e3; -var PLUGIN_VERSION = true ? "0.47.0" : "0.43.0"; +var PLUGIN_VERSION = true ? "0.48.0" : "0.48.0"; var ACTIVE_SESSION_TTL_MS = 60 * 60 * 1e3; var DAU_STAMP_PATH = join(homedir(), ".config", "vercel-plugin", "dau-stamp"); var FIRST_USE_STAMP_PATH = join(homedir(), ".config", "vercel-plugin", "first-use-stamp"); diff --git a/package.json b/package.json index 22df0dd8..44373296 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vercel-plugin", - "version": "0.47.0", + "version": "0.48.0", "private": true, "license": "Apache-2.0", "bin": { diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index 7a04ac8b..82620041 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -138,7 +138,7 @@ describe("telemetry controls", () => { expect(result.activeSessionMarker).toEqual({ schema: 1, active: true, - pluginVersion: "0.47.0", + pluginVersion: "0.48.0", updatedAt: Date.parse("2026-05-15T12:00:00.000Z"), expiresAt: Date.parse("2026-05-15T13:00:00.000Z"), }); @@ -154,7 +154,7 @@ describe("telemetry controls", () => { }), expect.objectContaining({ key: "plugin:version", - value: "0.47.0", + value: "0.48.0", }), ], ]); From 38cae2b5738c2ed0027676e9b21cc4ce6c3f1ef7 Mon Sep 17 00:00:00 2001 From: melkeydev Date: Tue, 11 Aug 2026 23:08:39 -0700 Subject: [PATCH 4/8] fixing headers --- README.md | 8 +- bun.lock | 3 + hooks/session-start-profiler-platform.test.ts | 62 +-- hooks/session-start-profiler.mjs | 409 +++++++++++++++++- hooks/src/session-start-profiler.mts | 74 ++-- hooks/src/telemetry.mts | 22 +- hooks/telemetry.mjs | 24 +- package.json | 1 + tests/telemetry.test.ts | 14 +- 9 files changed, 516 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index befb231a..cafbb412 100644 --- a/README.md +++ b/README.md @@ -126,23 +126,23 @@ What is collected: - `dau:active_today`: sent at most once per UTC day when the plugin runs. - `plugin:first_use`: sent once per local user profile the first time the plugin successfully reports telemetry. - `plugin:version`: sent with telemetry batches so usage can be grouped by plugin version. +- `plugin:install_id`: the locally stored random installation UUID. +- `plugin:agent_harness`: the detected agent harness or `unknown`. Each telemetry event contains only: - `id`: a random event UUID. - `event_time`: the event timestamp. - `key`: one of the event names listed above. -- `value`: `"1"` for counters or the plugin version for `plugin:version`. +- `value`: `"1"` for counters, the plugin version, the random installation UUID, or the detected harness, depending on the event key. The request also sends HTTP headers used by the telemetry bridge: - `x-vercel-plugin-topic-id: dau` - `x-vercel-plugin-session-id`: a random UUID generated for that telemetry request. - `x-vercel-plugin-version`: the plugin version embedded at build time. -- `x-vercel-plugin-installation-id`: the locally stored random installation UUID. -- `x-vercel-plugin-agent-harness`: the detected agent harness or `unknown`. -The installation ID is generated on the first telemetry-enabled plugin session and reused for that local installation. It is not derived from device, account, project, or user information. The harness value identifies Claude Code, Cursor, Codex, GitHub Copilot, Kimi Code, or Grok; ambiguous harnesses are reported as `unknown`. +The installation ID is generated on the first telemetry-enabled plugin session and reused for that local installation. It is not derived from device, account, project, or user information. The harness value identifies Claude Code, Cursor, Codex, GitHub Copilot, Kimi Code, or Grok using [`detect-agent`](https://github.com/vercel/detect-agent); ambiguous, unsupported, and custom harness names are reported as `unknown`. Prompt text, bash commands, tool-call contents, file paths, project names, account IDs, harness versions, and skill-injection details are not collected. diff --git a/bun.lock b/bun.lock index c846c153..19571907 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "vercel-plugin", "dependencies": { + "detect-agent": "1.2.0", "minisearch": "^7.2.0", }, "devDependencies": { @@ -193,6 +194,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "detect-agent": ["detect-agent@1.2.0", "", {}, "sha512-fW/515FHIogLopGDTSMc4XXkJV6mkVC10gVPoenb7cgY7PZAF/jakLDdDUBQ9QpJEA9AYgEg3/B9WqLTUtz3Ag=="], + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], diff --git a/hooks/session-start-profiler-platform.test.ts b/hooks/session-start-profiler-platform.test.ts index f8c837d8..c7cd5409 100644 --- a/hooks/session-start-profiler-platform.test.ts +++ b/hooks/session-start-profiler-platform.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { detectAgentHarness, detectSessionStartPlatform, + normalizeDetectedAgentHarness, } from "./src/session-start-profiler.mts"; describe("session-start-profiler platform detection", () => { @@ -29,35 +30,42 @@ describe("session-start-profiler platform detection", () => { ).toBe("claude-code"); }); - test("detects supported agent harnesses from explicit signals", () => { - expect(detectAgentHarness({ cursor_version: "1.0.0" }, {})).toBe("cursor"); - expect(detectAgentHarness({}, { COPILOT_PLUGIN_DATA: "/tmp/copilot-data" })).toBe( - "github-copilot", - ); - expect(detectAgentHarness({}, { KIMI_PLUGIN_ROOT: "/tmp/kimi-plugin" })).toBe( - "kimi", - ); - expect( - detectAgentHarness({}, { - GROK_PLUGIN_DATA: "/tmp/grok-data", - PLUGIN_DATA: "/tmp/compat-data", - }), - ).toBe("grok"); - expect( - detectAgentHarness({}, { - PLUGIN_DATA: "/tmp/codex-data", - CLAUDE_PLUGIN_DATA: "/tmp/compat-data", - }), - ).toBe("codex"); - expect(detectAgentHarness({}, { CLAUDE_ENV_FILE: "/tmp/claude.env" })).toBe( - "claude-code", + test("normalizes supported detect-agent names", () => { + expect(normalizeDetectedAgentHarness("cursor")).toBe("cursor"); + expect(normalizeDetectedAgentHarness("cursor-cli")).toBe("cursor"); + expect(normalizeDetectedAgentHarness("github-copilot")).toBe("github-copilot"); + expect(normalizeDetectedAgentHarness("kimi")).toBe("kimi"); + expect(normalizeDetectedAgentHarness("grok")).toBe("grok"); + expect(normalizeDetectedAgentHarness("codex_cli")).toBe("codex"); + expect(normalizeDetectedAgentHarness("claude_code")).toBe("claude-code"); + }); + + test("never forwards unsupported or custom agent names", () => { + expect(normalizeDetectedAgentHarness(undefined)).toBe("unknown"); + expect(normalizeDetectedAgentHarness("custom-agent@1")).toBe("unknown"); + expect(normalizeDetectedAgentHarness("devin")).toBe("unknown"); + }); + + test("uses Cursor hook fields before detect-agent", async () => { + let detectorCalled = false; + const harness = await detectAgentHarness( + { cursor_version: "1.0.0" }, + async () => { + detectorCalled = true; + return { isAgent: true, agent: { name: "claude_code" } }; + }, ); + + expect(harness).toBe("cursor"); + expect(detectorCalled).toBe(false); }); - test("returns unknown instead of guessing from ambiguous environment state", () => { - expect(detectAgentHarness({}, {})).toBe("unknown"); - expect(detectAgentHarness({}, { CODEX_HOME: "/tmp/codex" })).toBe("unknown"); - expect(detectAgentHarness({}, { KIMI_CODE_HOME: "/tmp/kimi" })).toBe("unknown"); - expect(detectAgentHarness({}, { CLAUDE_PLUGIN_ROOT: "/tmp/plugin" })).toBe("unknown"); + test("uses detect-agent for non-Cursor hooks", async () => { + expect( + await detectAgentHarness({}, async () => ({ + isAgent: true, + agent: { name: "grok" }, + })), + ).toBe("grok"); }); }); diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index 15115eb0..6f6736a9 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -1,3 +1,361 @@ +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { + get: (a, b) => (typeof require !== "undefined" ? require : a)[b] +}) : x)(function(x) { + if (typeof require !== "undefined") return require.apply(this, arguments); + throw Error('Dynamic require of "' + x + '" is not supported'); +}); +var __commonJS = (cb, mod) => function __require2() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); + +// node_modules/detect-agent/dist/index.js +var require_dist = __commonJS({ + "node_modules/detect-agent/dist/index.js"(exports, module) { + "use strict"; + var __defProp2 = Object.defineProperty; + var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; + var __getOwnPropNames2 = Object.getOwnPropertyNames; + var __hasOwnProp2 = Object.prototype.hasOwnProperty; + var __export = (target, all) => { + for (var name in all) + __defProp2(target, name, { get: all[name], enumerable: true }); + }; + var __copyProps2 = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames2(from)) + if (!__hasOwnProp2.call(to, key) && key !== except) + __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); + } + return to; + }; + var __toCommonJS = (mod) => __copyProps2(__defProp2({}, "__esModule", { value: true }), mod); + var index_exports = {}; + __export(index_exports, { + KNOWN_AGENTS: () => KNOWN_AGENTS, + determineAgent: () => determineAgent + }); + module.exports = __toCommonJS(index_exports); + var agents_default = { + $schema: "./agents.schema.json", + version: 1, + description: "Language-agnostic specification for detecting AI agents and automated development environments. Agents are evaluated in array order; the first agent whose `match` condition is satisfied wins. Every agent's `match` is a combinator (`anyOf`/`allOf`) whose `conditions` are evaluated as a tree: combinators nest, and `env_set`/`env_value`/`file_exists` are leaf checks. See agents.schema.json for the full structure.", + aiAgentVar: "AI_AGENT", + agents: [ + { + key: "CURSOR", + name: "cursor", + match: { + type: "anyOf", + conditions: [{ type: "env_set", name: "CURSOR_TRACE_ID" }] + } + }, + { + key: "CURSOR_CLI", + name: "cursor-cli", + match: { + type: "anyOf", + conditions: [ + { type: "env_set", name: "CURSOR_AGENT" }, + { + type: "env_value", + name: "CURSOR_EXTENSION_HOST_ROLE", + value: "agent-exec" + } + ] + } + }, + { + key: "KIMI", + name: "kimi", + description: "Kimi Code plugin hooks. KIMI_CODE_HOME may be configured outside an active Kimi session, so detection uses the plugin-scoped KIMI_PLUGIN_ROOT marker.", + match: { + type: "anyOf", + conditions: [{ type: "env_set", name: "KIMI_PLUGIN_ROOT" }] + } + }, + { + key: "GROK", + name: "grok", + description: "Grok Build plugin hooks. Evaluated before Claude Code because Grok supports Claude Code plugins and may expose compatibility markers.", + match: { + type: "anyOf", + conditions: [ + { type: "env_set", name: "GROK_PLUGIN_ROOT" }, + { type: "env_set", name: "GROK_PLUGIN_DATA" } + ] + } + }, + { + key: "GEMINI", + name: "gemini_cli", + match: { + type: "anyOf", + conditions: [{ type: "env_set", name: "GEMINI_CLI" }] + } + }, + { + key: "CLINE", + name: "cline", + match: { + type: "anyOf", + conditions: [{ type: "env_set", name: "CLINE_ACTIVE" }] + } + }, + { + key: "CODEX", + name: "codex_cli", + match: { + type: "anyOf", + conditions: [ + { type: "env_set", name: "CODEX_SANDBOX" }, + { type: "env_set", name: "CODEX_CI" }, + { type: "env_set", name: "CODEX_THREAD_ID" }, + { type: "env_set", name: "CODEX_SANDBOX_NETWORK_DISABLED" } + ] + } + }, + { + key: "ANTIGRAVITY", + name: "antigravity", + match: { + type: "anyOf", + conditions: [ + { type: "env_set", name: "ANTIGRAVITY_AGENT" }, + { type: "env_set", name: "ANTIGRAVITY_CLI_ALIAS" } + ] + } + }, + { + key: "AUGMENT_CLI", + name: "augment-cli", + match: { + type: "anyOf", + conditions: [{ type: "env_set", name: "AUGMENT_AGENT" }] + } + }, + { + key: "OPENCODE", + name: "open_code", + match: { + type: "anyOf", + conditions: [ + { type: "env_set", name: "OPENCODE_CLIENT" }, + { type: "env_set", name: "OPENCODE" } + ] + } + }, + { + key: "GOOSE", + name: "goose", + match: { + type: "anyOf", + conditions: [{ type: "env_set", name: "GOOSE_PROVIDER" }] + } + }, + { + key: "JUNIE", + name: "junie", + match: { + type: "anyOf", + conditions: [ + { type: "env_set", name: "JUNIE_DATA" }, + { type: "env_set", name: "JUNIE_SHIM_PATH" } + ] + } + }, + { + key: "PI", + name: "pi", + description: "Matches when the PATH contains a '.pi/agent' (or '.pi\\\\agent') segment.", + match: { + type: "anyOf", + conditions: [ + { + type: "env_matches", + name: "PATH", + pattern: "\\.pi[\\\\/]agent" + } + ] + } + }, + { + key: "COWORK", + name: "cowork", + description: "Claude Cowork. Evaluated before `claude_code` so the more specific COWORK marker wins. Requires CLAUDE_CODE_IS_COWORK plus a Claude Code marker.", + match: { + type: "allOf", + conditions: [ + { type: "env_set", name: "CLAUDE_CODE_IS_COWORK" }, + { + type: "anyOf", + conditions: [ + { type: "env_set", name: "CLAUDECODE" }, + { type: "env_set", name: "CLAUDE_CODE" } + ] + } + ] + } + }, + { + key: "CLAUDE", + name: "claude_code", + match: { + type: "anyOf", + conditions: [ + { type: "env_set", name: "CLAUDECODE" }, + { type: "env_set", name: "CLAUDE_CODE" } + ] + } + }, + { + key: "REPLIT", + name: "replit", + match: { + type: "anyOf", + conditions: [{ type: "env_set", name: "REPL_ID" }] + } + }, + { + key: "GITHUB_COPILOT", + name: "github-copilot", + match: { + type: "anyOf", + conditions: [ + { type: "env_set", name: "COPILOT_MODEL" }, + { type: "env_set", name: "COPILOT_ALLOW_ALL" }, + { type: "env_set", name: "COPILOT_GITHUB_TOKEN" } + ] + } + }, + { + key: "KIRO", + name: "kiro", + description: "AWS Kiro. TERM_PROGRAM=kiro is set by both the IDE terminal and the CLI agent, so gate on no_tty to avoid misdetecting a human at the integrated terminal.", + match: { + type: "allOf", + conditions: [ + { type: "env_matches", name: "TERM_PROGRAM", pattern: "kiro" }, + { type: "no_tty" } + ] + } + }, + { + key: "OPENCLAW", + name: "openclaw", + match: { + type: "anyOf", + conditions: [{ type: "env_set", name: "OPENCLAW_SHELL" }] + } + }, + { + key: "DEVIN", + name: "devin", + match: { + type: "anyOf", + conditions: [{ type: "file_exists", path: "/opt/.devin" }] + } + } + ] + }; + var import_promises = __require("fs/promises"); + var import_node_fs2 = __require("fs"); + async function evaluateCondition(condition) { + switch (condition.type) { + case "env_set": + return Boolean(process.env[condition.name]); + case "env_value": + return process.env[condition.name] === condition.value; + case "env_matches": { + const value = process.env[condition.name]; + if (!value) { + return false; + } + try { + return new RegExp(condition.pattern).test(value); + } catch { + return false; + } + } + case "no_tty": + return !process.stdout?.isTTY; + case "file_exists": + try { + await (0, import_promises.access)(condition.path, import_node_fs2.constants.F_OK); + return true; + } catch { + return false; + } + case "anyOf": + for (const sub of condition.conditions) { + if (await evaluateCondition(sub)) { + return true; + } + } + return false; + case "allOf": + for (const sub of condition.conditions) { + if (!await evaluateCondition(sub)) { + return false; + } + } + return true; + default: + return false; + } + } + var KNOWN_AGENTS = Object.fromEntries( + agents_default.agents.map(({ key, name }) => [key, name]) + ); + var agents = agents_default.agents; + var aiAgentVar = agents_default.aiAgentVar; + function resolveAiAgentStandard() { + const raw = process.env[aiAgentVar]; + if (!raw) { + return void 0; + } + const value = raw.trim(); + if (!value) { + return void 0; + } + return value; + } + async function determineAgent() { + const aiAgentStandard = resolveAiAgentStandard(); + if (aiAgentStandard) { + return { isAgent: true, agent: { name: aiAgentStandard } }; + } + for (const agent of agents) { + if (await evaluateCondition(agent.match)) { + return { isAgent: true, agent: { name: agent.name } }; + } + } + return { isAgent: false, agent: void 0 }; + } + } +}); + // hooks/src/session-start-profiler.mts import { accessSync, @@ -8,6 +366,7 @@ import { } from "fs"; import { delimiter, join, resolve } from "path"; import { execFileSync } from "child_process"; +import { createRequire } from "module"; import { fileURLToPath } from "url"; import { formatOutput, @@ -22,6 +381,8 @@ import { refreshActiveSessionMarker, trackDauActiveToday } from "./telemetry.mjs"; +var hookGlobal = globalThis; +hookGlobal.require ??= createRequire(import.meta.url); var FILE_MARKERS = [ { file: ".eve", skills: ["eve"] }, { file: "next.config.js", skills: ["nextjs", "turbopack"] }, @@ -325,30 +686,35 @@ function detectSessionStartPlatform(input, env = process.env) { } return "claude-code"; } -function hasNonEmptyEnv(env, key) { - const value = env[key]; - return typeof value === "string" && value.trim() !== ""; +function normalizeDetectedAgentHarness(name) { + switch (name) { + case "cursor": + case "cursor-cli": + return "cursor"; + case "claude_code": + return "claude-code"; + case "codex_cli": + return "codex"; + case "github-copilot": + return "github-copilot"; + case "kimi": + return "kimi"; + case "grok": + return "grok"; + default: + return "unknown"; + } } -function detectAgentHarness(input, env = process.env) { +async function determineAgentWithBundledPackage() { + const { determineAgent } = await Promise.resolve().then(() => __toESM(require_dist(), 1)); + return determineAgent(); +} +async function detectAgentHarness(input, detector = determineAgentWithBundledPackage) { if (input && ("conversation_id" in input || "cursor_version" in input)) { return "cursor"; } - if (hasNonEmptyEnv(env, "COPILOT_PLUGIN_DATA")) { - return "github-copilot"; - } - if (hasNonEmptyEnv(env, "KIMI_PLUGIN_ROOT")) { - return "kimi"; - } - if (hasNonEmptyEnv(env, "GROK_PLUGIN_ROOT") || hasNonEmptyEnv(env, "GROK_PLUGIN_DATA")) { - return "grok"; - } - if (hasNonEmptyEnv(env, "PLUGIN_DATA") || hasNonEmptyEnv(env, "PLUGIN_ROOT")) { - return "codex"; - } - if (hasNonEmptyEnv(env, "CLAUDE_ENV_FILE")) { - return "claude-code"; - } - return "unknown"; + const result = await detector(); + return normalizeDetectedAgentHarness(result.isAgent ? result.agent.name : void 0); } function normalizeSessionStartSessionId(input) { if (!input) return null; @@ -443,7 +809,7 @@ function formatSessionStartProfilerCursorOutput(envVars, userMessages) { async function main() { const hookInput = parseSessionStartInput(readFileSync(0, "utf8")); const platform = detectSessionStartPlatform(hookInput); - const agentHarness = detectAgentHarness(hookInput); + const agentHarness = await detectAgentHarness(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); const projectRoot = resolveSessionStartProjectRoot(); refreshActiveSessionMarker(); @@ -524,6 +890,7 @@ export { detectSessionStartPlatform, formatSessionStartProfilerCursorOutput, logBrokenSkillFrontmatterSummary, + normalizeDetectedAgentHarness, normalizeSessionStartSessionId, parseSessionStartInput, profileBootstrapSignals, diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index 6eb25379..14fb10df 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -21,7 +21,9 @@ import { import { homedir } from "node:os"; import { delimiter, join, resolve } from "node:path"; import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; +import type { AgentResult } from "detect-agent"; import { formatOutput, normalizeInput, @@ -38,6 +40,12 @@ import { type AgentHarness, } from "./telemetry.mjs"; +// detect-agent currently publishes CommonJS. The hook is bundled as a +// standalone ESM file, so provide Node's require implementation before its +// lazily bundled module is evaluated. +const hookGlobal = globalThis as typeof globalThis & { require?: NodeRequire }; +hookGlobal.require ??= createRequire(import.meta.url); + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -506,44 +514,48 @@ export function detectSessionStartPlatform( return "claude-code"; } -function hasNonEmptyEnv(env: NodeJS.ProcessEnv, key: string): boolean { - const value = env[key]; - return typeof value === "string" && value.trim() !== ""; -} - /** - * Detect only documented, explicit harness signals. Ambiguous sessions remain - * unknown rather than being inferred from installed binaries or local files. + * Map detect-agent output to the deliberately small set of values approved for + * plugin telemetry. Custom AI_AGENT values are never forwarded verbatim. */ -export function detectAgentHarness( - input: SessionStartInput | null, - env: NodeJS.ProcessEnv = process.env, -): AgentHarness { - if (input && ("conversation_id" in input || "cursor_version" in input)) { - return "cursor"; - } - - if (hasNonEmptyEnv(env, "COPILOT_PLUGIN_DATA")) { - return "github-copilot"; - } - - if (hasNonEmptyEnv(env, "KIMI_PLUGIN_ROOT")) { - return "kimi"; +export function normalizeDetectedAgentHarness(name: string | undefined): AgentHarness { + switch (name) { + case "cursor": + case "cursor-cli": + return "cursor"; + case "claude_code": + return "claude-code"; + case "codex_cli": + return "codex"; + case "github-copilot": + return "github-copilot"; + case "kimi": + return "kimi"; + case "grok": + return "grok"; + default: + return "unknown"; } +} - if (hasNonEmptyEnv(env, "GROK_PLUGIN_ROOT") || hasNonEmptyEnv(env, "GROK_PLUGIN_DATA")) { - return "grok"; - } +type AgentDetector = () => Promise; - if (hasNonEmptyEnv(env, "PLUGIN_DATA") || hasNonEmptyEnv(env, "PLUGIN_ROOT")) { - return "codex"; - } +async function determineAgentWithBundledPackage(): Promise { + const { determineAgent } = await import("detect-agent"); + return determineAgent(); +} - if (hasNonEmptyEnv(env, "CLAUDE_ENV_FILE")) { - return "claude-code"; +export async function detectAgentHarness( + input: SessionStartInput | null, + detector: AgentDetector = determineAgentWithBundledPackage, +): Promise { + // Cursor exposes reliable hook payload fields that detect-agent cannot inspect. + if (input && ("conversation_id" in input || "cursor_version" in input)) { + return "cursor"; } - return "unknown"; + const result = await detector(); + return normalizeDetectedAgentHarness(result.isAgent ? result.agent.name : undefined); } export function normalizeSessionStartSessionId(input: SessionStartInput | null): string | null { @@ -670,7 +682,7 @@ export function formatSessionStartProfilerCursorOutput( async function main(): Promise { const hookInput = parseSessionStartInput(readFileSync(0, "utf8")); const platform = detectSessionStartPlatform(hookInput); - const agentHarness = detectAgentHarness(hookInput); + const agentHarness = await detectAgentHarness(hookInput); const sessionId = normalizeSessionStartSessionId(hookInput); const projectRoot = resolveSessionStartProjectRoot(); refreshActiveSessionMarker(); diff --git a/hooks/src/telemetry.mts b/hooks/src/telemetry.mts index 66328c88..f8c8d002 100644 --- a/hooks/src/telemetry.mts +++ b/hooks/src/telemetry.mts @@ -47,8 +47,6 @@ export interface ActiveSessionMarker { async function sendTelemetry( events: TelemetryEvent[], - installationId: string | null, - agentHarness: AgentHarness, ): Promise { if (events.length === 0) return false; @@ -60,11 +58,7 @@ async function sendTelemetry( "x-vercel-plugin-topic-id": "dau", "x-vercel-plugin-session-id": randomUUID(), "x-vercel-plugin-version": PLUGIN_VERSION, - "x-vercel-plugin-agent-harness": agentHarness, }; - if (installationId) { - headers["x-vercel-plugin-installation-id"] = installationId; - } const response = await fetch(BRIDGE_ENDPOINT, { method: "POST", @@ -258,9 +252,23 @@ export async function trackDauActiveToday( key: "plugin:version", value: PLUGIN_VERSION, }); + if (installationId) { + events.push({ + id: randomUUID(), + event_time: eventTime, + key: "plugin:install_id", + value: installationId, + }); + } + events.push({ + id: randomUUID(), + event_time: eventTime, + key: "plugin:agent_harness", + value: agentHarness, + }); } - const sent = await sendTelemetry(events, installationId, agentHarness); + const sent = await sendTelemetry(events); if (sent) { for (const event of events) { diff --git a/hooks/telemetry.mjs b/hooks/telemetry.mjs index ab51f3fd..04d6293d 100644 --- a/hooks/telemetry.mjs +++ b/hooks/telemetry.mjs @@ -12,7 +12,7 @@ var FIRST_USE_STAMP_PATH = join(homedir(), ".config", "vercel-plugin", "first-us var INSTALLATION_ID_PATH = join(homedir(), ".config", "vercel-plugin", "installation-id"); var ACTIVE_SESSION_MARKER_PATH = join(homedir(), ".config", "vercel-plugin", "active-session.json"); var UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -async function sendTelemetry(events, installationId, agentHarness) { +async function sendTelemetry(events) { if (events.length === 0) return false; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS); @@ -21,12 +21,8 @@ async function sendTelemetry(events, installationId, agentHarness) { "Content-Type": "application/json", "x-vercel-plugin-topic-id": "dau", "x-vercel-plugin-session-id": randomUUID(), - "x-vercel-plugin-version": PLUGIN_VERSION, - "x-vercel-plugin-agent-harness": agentHarness + "x-vercel-plugin-version": PLUGIN_VERSION }; - if (installationId) { - headers["x-vercel-plugin-installation-id"] = installationId; - } const response = await fetch(BRIDGE_ENDPOINT, { method: "POST", headers, @@ -173,8 +169,22 @@ async function trackDauActiveToday(now = /* @__PURE__ */ new Date(), context = { key: "plugin:version", value: PLUGIN_VERSION }); + if (installationId) { + events.push({ + id: randomUUID(), + event_time: eventTime, + key: "plugin:install_id", + value: installationId + }); + } + events.push({ + id: randomUUID(), + event_time: eventTime, + key: "plugin:agent_harness", + value: agentHarness + }); } - const sent = await sendTelemetry(events, installationId, agentHarness); + const sent = await sendTelemetry(events); if (sent) { for (const event of events) { if (event.key === "dau:active_today") markDauPingSent(now); diff --git a/package.json b/package.json index 44373296..d7ae4fee 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "typescript": "^5.9.3" }, "dependencies": { + "detect-agent": "1.2.0", "minisearch": "^7.2.0" } } diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index 82620041..9450d717 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -156,13 +156,19 @@ describe("telemetry controls", () => { key: "plugin:version", value: "0.48.0", }), + expect.objectContaining({ + key: "plugin:install_id", + value: result.installationId, + }), + expect.objectContaining({ + key: "plugin:agent_harness", + value: "codex", + }), ], ]); expect(result.dauHeaders).toHaveLength(1); - expect(result.dauHeaders[0]["x-vercel-plugin-installation-id"]).toBe( - result.installationId, - ); - expect(result.dauHeaders[0]["x-vercel-plugin-agent-harness"]).toBe("codex"); + expect(result.dauHeaders[0]["x-vercel-plugin-installation-id"]).toBeUndefined(); + expect(result.dauHeaders[0]["x-vercel-plugin-agent-harness"]).toBeUndefined(); const repeated = await runTelemetryProbe({ agentHarness: "codex" }); expect(repeated.installationId).toBe(result.installationId); From 4b6eef3458657a306b358def073975a28a36690a Mon Sep 17 00:00:00 2001 From: melkeydev Date: Tue, 11 Aug 2026 23:29:38 -0700 Subject: [PATCH 5/8] adding harness calls --- README.md | 3 ++- hooks/src/telemetry.mts | 44 ++++++++++++++++++++++++++++++++++------- hooks/telemetry.mjs | 40 ++++++++++++++++++++++++++++++------- tests/telemetry.test.ts | 32 +++++++++++++++++++++++++++--- 4 files changed, 101 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index cafbb412..0c35c638 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ What is collected: - `plugin:first_use`: sent once per local user profile the first time the plugin successfully reports telemetry. - `plugin:version`: sent with telemetry batches so usage can be grouped by plugin version. - `plugin:install_id`: the locally stored random installation UUID. -- `plugin:agent_harness`: the detected agent harness or `unknown`. +- `plugin:agent_harness`: each distinct detected agent harness observed per installation per UTC day, or `unknown`. Each telemetry event contains only: @@ -152,6 +152,7 @@ How it is tracked: - The bridge only forwards events from plugin versions `0.40.0` and newer. - Local throttle files are stored under `~/.config/vercel-plugin/`: - `dau-stamp` prevents sending `dau:active_today` more than once per UTC day. + - `harness-stamp-` prevents sending the same `plugin:agent_harness` value more than once per UTC day. - `first-use-stamp` prevents sending `plugin:first_use` more than once. - `installation-id` stores the random installation UUID. It is used only by plugin telemetry and is not written to `active-session.json`. - Stamp files are written only after the telemetry bridge returns a successful response, so failed sends can retry later. diff --git a/hooks/src/telemetry.mts b/hooks/src/telemetry.mts index f8c8d002..dd476765 100644 --- a/hooks/src/telemetry.mts +++ b/hooks/src/telemetry.mts @@ -90,6 +90,10 @@ export function getInstallationIdPath(): string { return INSTALLATION_ID_PATH; } +export function getAgentHarnessStampPath(agentHarness: AgentHarness): string { + return join(homedir(), ".config", "vercel-plugin", `harness-stamp-${agentHarness}`); +} + export function getActiveSessionMarkerPath(): string { return ACTIVE_SESSION_MARKER_PATH; } @@ -144,6 +148,18 @@ export function shouldSendFirstUsePing(): boolean { } } +export function shouldSendAgentHarnessPing( + agentHarness: AgentHarness, + now: Date = new Date(), +): boolean { + try { + const existingMtime = statSync(getAgentHarnessStampPath(agentHarness)).mtime; + return utcDayStamp(existingMtime) !== utcDayStamp(now); + } catch { + return true; + } +} + export function markDauPingSent(now: Date = new Date()): void { void now; try { @@ -163,6 +179,16 @@ export function markFirstUsePingSent(): void { } } +export function markAgentHarnessPingSent(agentHarness: AgentHarness): void { + const stampPath = getAgentHarnessStampPath(agentHarness); + try { + mkdirSync(dirname(stampPath), { recursive: true }); + writeFileSync(stampPath, "", { flag: "w" }); + } catch { + // Best-effort + } +} + export function removeActiveSessionMarker(): void { try { rmSync(ACTIVE_SESSION_MARKER_PATH, { force: true }); @@ -224,6 +250,7 @@ export async function trackDauActiveToday( const installationId = getOrCreateInstallationId(); const agentHarness = context.agentHarness ?? "unknown"; + const shouldSendAgentHarness = shouldSendAgentHarnessPing(agentHarness, now); const eventTime = now.getTime(); const events: TelemetryEvent[] = []; @@ -245,7 +272,7 @@ export async function trackDauActiveToday( }); } - if (events.length > 0) { + if (events.length > 0 || shouldSendAgentHarness) { events.push({ id: randomUUID(), event_time: eventTime, @@ -260,12 +287,14 @@ export async function trackDauActiveToday( value: installationId, }); } - events.push({ - id: randomUUID(), - event_time: eventTime, - key: "plugin:agent_harness", - value: agentHarness, - }); + if (shouldSendAgentHarness) { + events.push({ + id: randomUUID(), + event_time: eventTime, + key: "plugin:agent_harness", + value: agentHarness, + }); + } } const sent = await sendTelemetry(events); @@ -274,6 +303,7 @@ export async function trackDauActiveToday( for (const event of events) { if (event.key === "dau:active_today") markDauPingSent(now); if (event.key === "plugin:first_use") markFirstUsePingSent(); + if (event.key === "plugin:agent_harness") markAgentHarnessPingSent(agentHarness); } } } diff --git a/hooks/telemetry.mjs b/hooks/telemetry.mjs index 04d6293d..ffb71f6c 100644 --- a/hooks/telemetry.mjs +++ b/hooks/telemetry.mjs @@ -45,6 +45,9 @@ function getFirstUseStampPath() { function getInstallationIdPath() { return INSTALLATION_ID_PATH; } +function getAgentHarnessStampPath(agentHarness) { + return join(homedir(), ".config", "vercel-plugin", `harness-stamp-${agentHarness}`); +} function getActiveSessionMarkerPath() { return ACTIVE_SESSION_MARKER_PATH; } @@ -91,6 +94,14 @@ function shouldSendFirstUsePing() { return true; } } +function shouldSendAgentHarnessPing(agentHarness, now = /* @__PURE__ */ new Date()) { + try { + const existingMtime = statSync(getAgentHarnessStampPath(agentHarness)).mtime; + return utcDayStamp(existingMtime) !== utcDayStamp(now); + } catch { + return true; + } +} function markDauPingSent(now = /* @__PURE__ */ new Date()) { void now; try { @@ -106,6 +117,14 @@ function markFirstUsePingSent() { } catch { } } +function markAgentHarnessPingSent(agentHarness) { + const stampPath = getAgentHarnessStampPath(agentHarness); + try { + mkdirSync(dirname(stampPath), { recursive: true }); + writeFileSync(stampPath, "", { flag: "w" }); + } catch { + } +} function removeActiveSessionMarker() { try { rmSync(ACTIVE_SESSION_MARKER_PATH, { force: true }); @@ -144,6 +163,7 @@ async function trackDauActiveToday(now = /* @__PURE__ */ new Date(), context = { if (!isDauTelemetryEnabled()) return; const installationId = getOrCreateInstallationId(); const agentHarness = context.agentHarness ?? "unknown"; + const shouldSendAgentHarness = shouldSendAgentHarnessPing(agentHarness, now); const eventTime = now.getTime(); const events = []; if (shouldSendDauPing(now)) { @@ -162,7 +182,7 @@ async function trackDauActiveToday(now = /* @__PURE__ */ new Date(), context = { value: "1" }); } - if (events.length > 0) { + if (events.length > 0 || shouldSendAgentHarness) { events.push({ id: randomUUID(), event_time: eventTime, @@ -177,33 +197,39 @@ async function trackDauActiveToday(now = /* @__PURE__ */ new Date(), context = { value: installationId }); } - events.push({ - id: randomUUID(), - event_time: eventTime, - key: "plugin:agent_harness", - value: agentHarness - }); + if (shouldSendAgentHarness) { + events.push({ + id: randomUUID(), + event_time: eventTime, + key: "plugin:agent_harness", + value: agentHarness + }); + } } const sent = await sendTelemetry(events); if (sent) { for (const event of events) { if (event.key === "dau:active_today") markDauPingSent(now); if (event.key === "plugin:first_use") markFirstUsePingSent(); + if (event.key === "plugin:agent_harness") markAgentHarnessPingSent(agentHarness); } } } export { PLUGIN_VERSION, getActiveSessionMarkerPath, + getAgentHarnessStampPath, getDauStampPath, getFirstUseStampPath, getInstallationIdPath, getTelemetryOverride, isDauTelemetryEnabled, + markAgentHarnessPingSent, markDauPingSent, markFirstUsePingSent, refreshActiveSessionMarker, removeActiveSessionMarker, + shouldSendAgentHarnessPing, shouldSendDauPing, shouldSendFirstUsePing, trackDauActiveToday diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index 9450d717..bf1efd34 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -12,6 +12,7 @@ let tempHome: string; async function runTelemetryProbe(options: { telemetryEnv?: string; agentHarness?: string; + agentHarnesses?: string[]; }): Promise<{ dauEnabled: boolean; calls: number; @@ -49,9 +50,12 @@ async function runTelemetryProbe(options: { }; const dauEnabled = telemetry.isDauTelemetryEnabled(); - const context = { agentHarness: ${JSON.stringify(options.agentHarness ?? "unknown")} }; - await telemetry.trackDauActiveToday(undefined, context); - await telemetry.trackDauActiveToday(undefined, context); + const agentHarnesses = ${JSON.stringify( + options.agentHarnesses ?? [options.agentHarness ?? "unknown", options.agentHarness ?? "unknown"], + )}; + for (const agentHarness of agentHarnesses) { + await telemetry.trackDauActiveToday(undefined, { agentHarness }); + } const stampPath = telemetry.getDauStampPath(); const firstUseStampPath = telemetry.getFirstUseStampPath(); @@ -175,6 +179,28 @@ describe("telemetry controls", () => { expect(repeated.calls).toBe(0); }); + test("reports each harness once per UTC day without inflating DAU", async () => { + const result = await runTelemetryProbe({ + agentHarnesses: ["claude-code", "claude-code", "cursor", "cursor"], + }); + + expect(result.calls).toBe(2); + expect(result.dauPayloads).toHaveLength(2); + + const events = result.dauPayloads.flat() as Array<{ key: string; value: string }>; + expect(events.filter((event) => event.key === "dau:active_today")).toHaveLength(1); + expect( + events + .filter((event) => event.key === "plugin:agent_harness") + .map((event) => event.value), + ).toEqual(["claude-code", "cursor"]); + + const secondPayload = result.dauPayloads[1] as Array<{ key: string; value: string }>; + expect(secondPayload.some((event) => event.key === "dau:active_today")).toBe(false); + expect(secondPayload.some((event) => event.key === "plugin:version")).toBe(true); + expect(secondPayload.some((event) => event.key === "plugin:install_id")).toBe(true); + }); + test("compiled hooks do not emit prompt, tool, or skill-injection telemetry keys", () => { const pretoolHook = readFileSync(join(ROOT, "hooks", "pretooluse-skill-inject.mjs"), "utf-8"); const promptSkillInjectHook = readFileSync(join(ROOT, "hooks", "user-prompt-submit-skill-inject.mjs"), "utf-8"); From 0bc34d1b7975dd053fc7c78db3b2c1c1dc7fbb79 Mon Sep 17 00:00:00 2001 From: melkeydev Date: Wed, 12 Aug 2026 09:58:39 -0700 Subject: [PATCH 6/8] fixing installing-id --- hooks/src/telemetry.mts | 22 +++++++++++++--- hooks/telemetry.mjs | 15 +++++++++-- tests/telemetry.test.ts | 58 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/hooks/src/telemetry.mts b/hooks/src/telemetry.mts index dd476765..b45ff305 100644 --- a/hooks/src/telemetry.mts +++ b/hooks/src/telemetry.mts @@ -111,18 +111,32 @@ function getOrCreateInstallationId(): string | null { const existing = readInstallationId(); if (existing) return existing; + const installationId = randomUUID(); try { mkdirSync(dirname(INSTALLATION_ID_PATH), { recursive: true, mode: 0o700 }); - const installationId = randomUUID(); writeFileSync(INSTALLATION_ID_PATH, `${installationId}\n`, { flag: "wx", mode: 0o600, }); return installationId; } catch { - // Another process may have created the file first. Never send an - // ephemeral identifier when a stable value cannot be read from disk. - return readInstallationId(); + // Another process may have created the file first — prefer its value. + const raced = readInstallationId(); + if (raced) return raced; + + // Nothing valid is on disk, so replace the unusable file. Re-read after + // writing so a concurrent repair adopts the value currently on disk. + try { + writeFileSync(INSTALLATION_ID_PATH, `${installationId}\n`, { + flag: "w", + mode: 0o600, + }); + return readInstallationId(); + } catch { + // A concurrent repair may still have succeeded. Never send an + // identifier that cannot be confirmed on disk. + return readInstallationId(); + } } } diff --git a/hooks/telemetry.mjs b/hooks/telemetry.mjs index ffb71f6c..66be0f15 100644 --- a/hooks/telemetry.mjs +++ b/hooks/telemetry.mjs @@ -62,9 +62,9 @@ function readInstallationId() { function getOrCreateInstallationId() { const existing = readInstallationId(); if (existing) return existing; + const installationId = randomUUID(); try { mkdirSync(dirname(INSTALLATION_ID_PATH), { recursive: true, mode: 448 }); - const installationId = randomUUID(); writeFileSync(INSTALLATION_ID_PATH, `${installationId} `, { flag: "wx", @@ -72,7 +72,18 @@ function getOrCreateInstallationId() { }); return installationId; } catch { - return readInstallationId(); + const raced = readInstallationId(); + if (raced) return raced; + try { + writeFileSync(INSTALLATION_ID_PATH, `${installationId} +`, { + flag: "w", + mode: 384 + }); + return readInstallationId(); + } catch { + return readInstallationId(); + } } } function utcDayStamp(date) { diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index bf1efd34..1acc632a 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -13,6 +13,7 @@ async function runTelemetryProbe(options: { telemetryEnv?: string; agentHarness?: string; agentHarnesses?: string[]; + refreshActiveSessionMarker?: boolean; }): Promise<{ dauEnabled: boolean; calls: number; @@ -66,7 +67,9 @@ async function runTelemetryProbe(options: { : null ); const activeSessionMarkerPath = telemetry.getActiveSessionMarkerPath(); - telemetry.refreshActiveSessionMarker(new Date("2026-05-15T12:00:00.000Z")); + if (${JSON.stringify(options.refreshActiveSessionMarker ?? true)}) { + telemetry.refreshActiveSessionMarker(new Date("2026-05-15T12:00:00.000Z")); + } const activeSessionMarker = await import("node:fs").then((fs) => fs.existsSync(activeSessionMarkerPath) ? JSON.parse(fs.readFileSync(activeSessionMarkerPath, "utf-8")) @@ -179,6 +182,57 @@ describe("telemetry controls", () => { expect(repeated.calls).toBe(0); }); + test("repairs an invalid installation ID before sending telemetry", async () => { + const installationIdPath = join(tempHome, ".config", "vercel-plugin", "installation-id"); + mkdirSync(join(tempHome, ".config", "vercel-plugin"), { recursive: true }); + writeFileSync(installationIdPath, "not-a-uuid\n"); + + const result = await runTelemetryProbe({ agentHarness: "codex" }); + + expect(result.installationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect(readFileSync(installationIdPath, "utf8").trim()).toBe(result.installationId); + expect( + (result.dauPayloads[0] as Array<{ key: string; value: string }>).find( + (event) => event.key === "plugin:install_id", + )?.value, + ).toBe(result.installationId); + }); + + test("concurrent invalid-file repairs leave a valid installation ID", async () => { + const installationIdPath = join(tempHome, ".config", "vercel-plugin", "installation-id"); + mkdirSync(join(tempHome, ".config", "vercel-plugin"), { recursive: true }); + writeFileSync(installationIdPath, "not-a-uuid\n"); + + const results = await Promise.all( + Array.from({ length: 4 }, () => + runTelemetryProbe({ agentHarness: "codex", refreshActiveSessionMarker: false }), + ), + ); + const storedInstallationId = readFileSync(installationIdPath, "utf8").trim(); + + expect(storedInstallationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + for (const result of results) { + expect(result.installationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + } + }); + + test("telemetry opt-out does not repair an invalid installation ID", async () => { + const installationIdPath = join(tempHome, ".config", "vercel-plugin", "installation-id"); + mkdirSync(join(tempHome, ".config", "vercel-plugin"), { recursive: true }); + writeFileSync(installationIdPath, "not-a-uuid\n"); + + const result = await runTelemetryProbe({ telemetryEnv: "off", agentHarness: "codex" }); + + expect(result.calls).toBe(0); + expect(readFileSync(installationIdPath, "utf8")).toBe("not-a-uuid\n"); + }); + test("reports each harness once per UTC day without inflating DAU", async () => { const result = await runTelemetryProbe({ agentHarnesses: ["claude-code", "claude-code", "cursor", "cursor"], From 42cb5c3e7846d2aad774aeb935321bf6f567453b Mon Sep 17 00:00:00 2001 From: melkeydev Date: Wed, 12 Aug 2026 13:01:03 -0700 Subject: [PATCH 7/8] fixing detect agent logic to include more --- README.md | 4 +-- hooks/session-start-profiler-platform.test.ts | 34 +++++++++++++++++-- hooks/session-start-profiler.mjs | 3 +- hooks/src/session-start-profiler.mts | 6 ++-- hooks/src/telemetry.mts | 1 + 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0c35c638..244488fe 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ What is collected: - `plugin:first_use`: sent once per local user profile the first time the plugin successfully reports telemetry. - `plugin:version`: sent with telemetry batches so usage can be grouped by plugin version. - `plugin:install_id`: the locally stored random installation UUID. -- `plugin:agent_harness`: each distinct detected agent harness observed per installation per UTC day, or `unknown`. +- `plugin:agent_harness`: each distinct detected agent harness category observed per installation per UTC day. Each telemetry event contains only: @@ -142,7 +142,7 @@ The request also sends HTTP headers used by the telemetry bridge: - `x-vercel-plugin-session-id`: a random UUID generated for that telemetry request. - `x-vercel-plugin-version`: the plugin version embedded at build time. -The installation ID is generated on the first telemetry-enabled plugin session and reused for that local installation. It is not derived from device, account, project, or user information. The harness value identifies Claude Code, Cursor, Codex, GitHub Copilot, Kimi Code, or Grok using [`detect-agent`](https://github.com/vercel/detect-agent); ambiguous, unsupported, and custom harness names are reported as `unknown`. +The installation ID is generated on the first telemetry-enabled plugin session and reused for that local installation. It is not derived from device, account, project, or user information. The harness value identifies Claude Code (including Claude Cowork), Cursor, Codex, GitHub Copilot, Kimi Code, or Grok using [`detect-agent`](https://github.com/vercel/detect-agent). A detected but unsupported or custom harness is reported as `other`; `unknown` means no harness was detected. Raw custom harness names are never sent. Prompt text, bash commands, tool-call contents, file paths, project names, account IDs, harness versions, and skill-injection details are not collected. diff --git a/hooks/session-start-profiler-platform.test.ts b/hooks/session-start-profiler-platform.test.ts index c7cd5409..86b9c395 100644 --- a/hooks/session-start-profiler-platform.test.ts +++ b/hooks/session-start-profiler-platform.test.ts @@ -38,12 +38,28 @@ describe("session-start-profiler platform detection", () => { expect(normalizeDetectedAgentHarness("grok")).toBe("grok"); expect(normalizeDetectedAgentHarness("codex_cli")).toBe("codex"); expect(normalizeDetectedAgentHarness("claude_code")).toBe("claude-code"); + expect(normalizeDetectedAgentHarness("cowork")).toBe("claude-code"); }); - test("never forwards unsupported or custom agent names", () => { + test("distinguishes no detection from detected but unapproved agents", () => { expect(normalizeDetectedAgentHarness(undefined)).toBe("unknown"); - expect(normalizeDetectedAgentHarness("custom-agent@1")).toBe("unknown"); - expect(normalizeDetectedAgentHarness("devin")).toBe("unknown"); + for (const name of [ + "gemini_cli", + "cline", + "antigravity", + "augment-cli", + "open_code", + "goose", + "junie", + "pi", + "replit", + "kiro", + "openclaw", + "devin", + "custom-agent@1", + ]) { + expect(normalizeDetectedAgentHarness(name)).toBe("other"); + } }); test("uses Cursor hook fields before detect-agent", async () => { @@ -68,4 +84,16 @@ describe("session-start-profiler platform detection", () => { })), ).toBe("grok"); }); + + test("returns unknown only when detect-agent finds no agent", async () => { + expect( + await detectAgentHarness({}, async () => ({ isAgent: false })), + ).toBe("unknown"); + expect( + await detectAgentHarness({}, async () => ({ + isAgent: true, + agent: { name: "devin" }, + })), + ).toBe("other"); + }); }); diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index 6f6736a9..e5c491e7 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -692,6 +692,7 @@ function normalizeDetectedAgentHarness(name) { case "cursor-cli": return "cursor"; case "claude_code": + case "cowork": return "claude-code"; case "codex_cli": return "codex"; @@ -702,7 +703,7 @@ function normalizeDetectedAgentHarness(name) { case "grok": return "grok"; default: - return "unknown"; + return name === void 0 ? "unknown" : "other"; } } async function determineAgentWithBundledPackage() { diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index 14fb10df..d71fd4cf 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -516,7 +516,8 @@ export function detectSessionStartPlatform( /** * Map detect-agent output to the deliberately small set of values approved for - * plugin telemetry. Custom AI_AGENT values are never forwarded verbatim. + * plugin telemetry. Custom AI_AGENT values are never forwarded verbatim, and a + * detected but unapproved agent is distinguishable from no detection. */ export function normalizeDetectedAgentHarness(name: string | undefined): AgentHarness { switch (name) { @@ -524,6 +525,7 @@ export function normalizeDetectedAgentHarness(name: string | undefined): AgentHa case "cursor-cli": return "cursor"; case "claude_code": + case "cowork": return "claude-code"; case "codex_cli": return "codex"; @@ -534,7 +536,7 @@ export function normalizeDetectedAgentHarness(name: string | undefined): AgentHa case "grok": return "grok"; default: - return "unknown"; + return name === undefined ? "unknown" : "other"; } } diff --git a/hooks/src/telemetry.mts b/hooks/src/telemetry.mts index b45ff305..f2c44fa8 100644 --- a/hooks/src/telemetry.mts +++ b/hooks/src/telemetry.mts @@ -24,6 +24,7 @@ export type AgentHarness = | "github-copilot" | "kimi" | "grok" + | "other" | "unknown"; export interface TelemetryContext { From 22e272ae6d4738d043ce297d96c87ab693d8efd8 Mon Sep 17 00:00:00 2001 From: melkeydev Date: Wed, 12 Aug 2026 13:11:21 -0700 Subject: [PATCH 8/8] fixing await to not block other calls --- hooks/session-start-profiler-platform.test.ts | 8 +++++++ hooks/session-start-profiler.mjs | 12 ++++++---- hooks/src/session-start-profiler.mts | 23 ++++++++++++------- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/hooks/session-start-profiler-platform.test.ts b/hooks/session-start-profiler-platform.test.ts index 86b9c395..07eb246e 100644 --- a/hooks/session-start-profiler-platform.test.ts +++ b/hooks/session-start-profiler-platform.test.ts @@ -96,4 +96,12 @@ describe("session-start-profiler platform detection", () => { })), ).toBe("other"); }); + + test("falls back to unknown when detect-agent rejects", async () => { + expect( + await detectAgentHarness({}, async () => { + throw new Error("detector failed"); + }), + ).toBe("unknown"); + }); }); diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index e5c491e7..8eac1c75 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -381,8 +381,6 @@ import { refreshActiveSessionMarker, trackDauActiveToday } from "./telemetry.mjs"; -var hookGlobal = globalThis; -hookGlobal.require ??= createRequire(import.meta.url); var FILE_MARKERS = [ { file: ".eve", skills: ["eve"] }, { file: "next.config.js", skills: ["nextjs", "turbopack"] }, @@ -707,6 +705,8 @@ function normalizeDetectedAgentHarness(name) { } } async function determineAgentWithBundledPackage() { + const hookGlobal = globalThis; + hookGlobal.require ??= createRequire(import.meta.url); const { determineAgent } = await Promise.resolve().then(() => __toESM(require_dist(), 1)); return determineAgent(); } @@ -714,8 +714,12 @@ async function detectAgentHarness(input, detector = determineAgentWithBundledPac if (input && ("conversation_id" in input || "cursor_version" in input)) { return "cursor"; } - const result = await detector(); - return normalizeDetectedAgentHarness(result.isAgent ? result.agent.name : void 0); + try { + const result = await detector(); + return normalizeDetectedAgentHarness(result.isAgent ? result.agent.name : void 0); + } catch { + return "unknown"; + } } function normalizeSessionStartSessionId(input) { if (!input) return null; diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index d71fd4cf..ba6b8921 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -40,12 +40,6 @@ import { type AgentHarness, } from "./telemetry.mjs"; -// detect-agent currently publishes CommonJS. The hook is bundled as a -// standalone ESM file, so provide Node's require implementation before its -// lazily bundled module is evaluated. -const hookGlobal = globalThis as typeof globalThis & { require?: NodeRequire }; -hookGlobal.require ??= createRequire(import.meta.url); - // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -543,6 +537,13 @@ export function normalizeDetectedAgentHarness(name: string | undefined): AgentHa type AgentDetector = () => Promise; async function determineAgentWithBundledPackage(): Promise { + // detect-agent currently publishes CommonJS. The hook is bundled as a + // standalone ESM file, so provide Node's require implementation immediately + // before its lazily bundled module is evaluated. This runs inside + // detectAgentHarness's failure boundary. + const hookGlobal = globalThis as typeof globalThis & { require?: NodeRequire }; + hookGlobal.require ??= createRequire(import.meta.url); + const { determineAgent } = await import("detect-agent"); return determineAgent(); } @@ -556,8 +557,14 @@ export async function detectAgentHarness( return "cursor"; } - const result = await detector(); - return normalizeDetectedAgentHarness(result.isAgent ? result.agent.name : undefined); + try { + const result = await detector(); + return normalizeDetectedAgentHarness(result.isAgent ? result.agent.name : undefined); + } catch { + // Harness detection is best-effort and must never block session startup, + // the active-session marker, or DAU telemetry. + return "unknown"; + } } export function normalizeSessionStartSessionId(input: SessionStartInput | null): string | null {