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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/vite-plugin-agent-explorer-hint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cloudflare/vite-plugin": minor
---

Surface Local Explorer API to headless agents

When a Vite dev or preview server with the Cloudflare plugin is started in a headless AI agent environment, the plugin now prints the Local Explorer API URL and useful resource routes to stdout so agents can discover and call them programmatically.
1 change: 1 addition & 0 deletions packages/vite-plugin-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"@types/node": "catalog:default",
"@types/semver": "^7.5.1",
"@types/ws": "^8.5.13",
"am-i-vibing": "^0.5.0",
"defu": "^6.1.4",
"get-port": "^7.1.0",
"magic-string": "^0.30.12",
Expand Down
130 changes: 130 additions & 0 deletions packages/vite-plugin-cloudflare/src/__tests__/agent-hint.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, test, vi } from "vitest";
import * as detectAgentModule from "../detect-agent";
import { maybeAddAgentHint } from "../plugins/agent-hint";
import type * as vite from "vite";

function createMockServer(serverLogs: { info: string[] }) {
const mockLogger: vite.Logger = {
info: (msg: string) => serverLogs.info.push(msg),
warn: vi.fn(),
warnOnce: vi.fn(),
error: vi.fn(),
clearScreen: vi.fn(),
hasErrorLogged: () => false,
hasWarned: false,
};

return {
config: { logger: mockLogger },
resolvedUrls: {
local: ["http://localhost:5173/"],
network: [],
},
bindCLIShortcuts: vi.fn(),
} as unknown as vite.ViteDevServer;
}

describe("Local Explorer agent hint", () => {
let savedIsTTY: typeof process.stdin.isTTY;
let serverLogs: { info: string[] };
let mockServer: vite.ViteDevServer;

beforeEach(() => {
savedIsTTY = process.stdin.isTTY;
serverLogs = { info: [] };
mockServer = createMockServer(serverLogs);
});

afterEach(() => {
process.stdin.isTTY = savedIsTTY;
vi.restoreAllMocks();
});

test("prints hint with 'dev' for dev sessions", ({ expect }) => {
process.stdin.isTTY = false;
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true);

const originalBindCLIShortcuts = mockServer.bindCLIShortcuts;
maybeAddAgentHint(mockServer, "dev");

expect(mockServer.bindCLIShortcuts).not.toBe(originalBindCLIShortcuts);

mockServer.bindCLIShortcuts({ print: true });

expect(originalBindCLIShortcuts).toHaveBeenCalledOnce();
const output = serverLogs.info.join("\n");
expect(output).toContain(
"The Cloudflare Vite plugin detected this dev session is running in an AI agent."
);
expect(output).toContain(
"The Local Explorer API is available at http://localhost:5173/cdn-cgi/local/explorer/api"
);
expect(output).toContain(
"GET http://localhost:5173/cdn-cgi/local/explorer/api/local/workers - local Workers and bindings"
);
expect(output).toContain(
"POST http://localhost:5173/cdn-cgi/local/explorer/api/local/observability/query"
);
// The OpenAPI schema is listed last, as a fallback, so agents reach for the
// specific routes first.
expect(output.indexOf("- OpenAPI schema")).toBeGreaterThan(
output.indexOf("- Workflows")
);
});

test("prints hint with 'preview' for preview sessions", ({ expect }) => {
process.stdin.isTTY = false;
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true);

maybeAddAgentHint(mockServer, "preview");
mockServer.bindCLIShortcuts({ print: true });

const output = serverLogs.info.join("\n");
expect(output).toContain(
"The Cloudflare Vite plugin detected this preview session is running in an AI agent."
);
});

test("does not print hint when print option is false", ({ expect }) => {
process.stdin.isTTY = false;
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true);

maybeAddAgentHint(mockServer, "dev");
mockServer.bindCLIShortcuts();

expect(serverLogs.info).toHaveLength(0);
});

test("does not patch for interactive sessions", ({ expect }) => {
process.stdin.isTTY = true;
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true);

const originalBindCLIShortcuts = mockServer.bindCLIShortcuts;
maybeAddAgentHint(mockServer, "dev");

expect(mockServer.bindCLIShortcuts).toBe(originalBindCLIShortcuts);
});

test("does not patch for non-agent sessions", ({ expect }) => {
process.stdin.isTTY = false;
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(false);

const originalBindCLIShortcuts = mockServer.bindCLIShortcuts;
maybeAddAgentHint(mockServer, "dev");

expect(mockServer.bindCLIShortcuts).toBe(originalBindCLIShortcuts);
});

test("does not patch when Local Explorer is disabled", ({ expect }) => {
process.stdin.isTTY = false;
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true);
vi.stubEnv("X_LOCAL_EXPLORER", "false");

const originalBindCLIShortcuts = mockServer.bindCLIShortcuts;
maybeAddAgentHint(mockServer, "dev");

expect(mockServer.bindCLIShortcuts).toBe(originalBindCLIShortcuts);

vi.stubEnv("X_LOCAL_EXPLORER", undefined);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { afterEach, describe, test } from "vitest";
import { isAgentSession } from "../detect-agent";

describe("isAgentSession", () => {
const saved = { ...process.env };

afterEach(() => {
process.env = { ...saved };
});

test("detects a headless agent from the environment", ({ expect }) => {
process.env = { CLAUDECODE: "1" };
expect(isAgentSession()).toBe(true);
});

test("treats a hybrid terminal as interactive, not an agent", ({
expect,
}) => {
// Warp embeds agentic features but has a human at the keyboard. The
// library's isAgent() reports hybrid as an agent, so this guards the
// stricter check.
process.env = { TERM_PROGRAM: "WarpTerminal" };
expect(isAgentSession()).toBe(false);
});

test("is false in a plain shell", ({ expect }) => {
process.env = {};
expect(isAgentSession()).toBe(false);
});
});
30 changes: 30 additions & 0 deletions packages/vite-plugin-cloudflare/src/detect-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// eslint-disable-next-line no-restricted-imports -- This is the canonical wrapper around am-i-vibing; all other code should use isAgentSession() from this module
import { detectAgenticEnvironment } from "am-i-vibing";

// Process tree traversal shells out to `ps`, which is slow (~75ms) and can time
// out in CI. Environment variables are enough to identify agentic environments.
// Passing an empty array keeps that off regardless of the library's default.
const NO_PROCESS_ANCESTRY: { command?: string }[] = [];

/**
* Detects whether the current process is being driven by an AI coding agent.
*
* True only when the detected type is exactly `"agent"`. Hybrid terminals (Warp,
* VS Code) embed agentic features but still have a human at the keyboard, so
* they're treated as interactive — note `isAgent()` from the library would
* report them as agents.
*
* Any error resolves to `false` rather than propagating.
*/
export function isAgentSession(): boolean {
try {
return (
detectAgenticEnvironment({
env: process.env,
processAncestry: NO_PROCESS_ANCESTRY,
}).type === "agent"
);
} catch {
return false;
}
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
2 changes: 2 additions & 0 deletions packages/vite-plugin-cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { isForcedBuildOutput } from "./build-output-env";
import { PluginContext } from "./context";
import { resolvePluginConfig } from "./plugin-config";
import { additionalModulesPlugin } from "./plugins/additional-modules";
import { agentHintPlugin } from "./plugins/agent-hint";
import { buildOutputPlugin } from "./plugins/build-output";
import { configPlugin } from "./plugins/config";
import { debugPlugin } from "./plugins/debug";
Expand Down Expand Up @@ -111,6 +112,7 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
tunnelPlugin(ctx),
previewPlugin(ctx),
shortcutsPlugin(ctx),
agentHintPlugin(ctx),
debugPlugin(ctx),
triggerHandlersPlugin(ctx),
virtualModulesPlugin(ctx),
Expand Down
95 changes: 95 additions & 0 deletions packages/vite-plugin-cloudflare/src/plugins/agent-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { getLocalExplorerEnabledFromEnv } from "@cloudflare/workers-utils";
import { CorePaths } from "miniflare";
import { isAgentSession } from "../detect-agent";
import { createPlugin } from "../utils";
import type * as vite from "vite";

/**
* Plugin that prints the Local Explorer API URL and useful routes when
* the dev server is started by a headless AI agent. This allows agents
* to discover and call the Local Explorer API programmatically.
*
* The hint is printed by patching `server.bindCLIShortcuts` so it
* appears after both the server URLs and the keyboard shortcut hints.
*/
export const agentHintPlugin = createPlugin("agent-hint", () => {
return {
configureServer(viteDevServer) {
maybeAddAgentHint(viteDevServer, "dev");
},
configurePreviewServer(vitePreviewServer) {
maybeAddAgentHint(vitePreviewServer, "preview");
},
};
});

/**
* If the session is a headless AI agent with Local Explorer enabled,
* patches `server.bindCLIShortcuts` to print the explorer API hint
* after the shortcut hints have been printed.
*
* @param server - The Vite dev or preview server
* @param mode - Whether this is a "dev" or "preview" session
*/
export function maybeAddAgentHint(
server: vite.ViteDevServer | vite.PreviewServer,
mode: "dev" | "preview"
): void {
if (
process.stdin.isTTY ||
!getLocalExplorerEnabledFromEnv() ||
!isAgentSession()
) {
return;
}

const originalBindCLIShortcuts = server.bindCLIShortcuts.bind(server);
server.bindCLIShortcuts = (options?: vite.BindCLIShortcutsOptions) => {
originalBindCLIShortcuts(options);
if (options?.print) {
printLocalExplorerAgentHint(server, mode);
}
};
}

/**
* Prints the Local Explorer API URL and useful routes to stdout so that
* headless AI agents can discover and call them programmatically.
*
* Keep the message in sync with the wrangler copy in
* packages/wrangler/src/dev/start-dev.ts.
*
* @param server - The Vite dev or preview server (must have `resolvedUrls` populated)
* @param mode - Whether this is a "dev" or "preview" session
*/
function printLocalExplorerAgentHint(
server: vite.ViteDevServer | vite.PreviewServer,
mode: "dev" | "preview"
): void {
const url = server.resolvedUrls?.local[0];
if (!url) {
return;
}

const explorerApiUrl = new URL(`${CorePaths.EXPLORER}/api`, url).href;

server.config.logger.info(
[
"",
`The Cloudflare Vite plugin detected this ${mode} session is running in an AI agent.`,
`The Local Explorer API is available at ${explorerApiUrl}`,
Comment thread
nickpatt marked this conversation as resolved.
`Useful routes:`,
` GET ${explorerApiUrl}/local/workers - local Workers and bindings`,
` GET ${explorerApiUrl}/storage/kv/namespaces - KV namespaces`,
` GET ${explorerApiUrl}/d1/database - D1 databases`,
` GET ${explorerApiUrl}/r2/buckets - R2 buckets`,
` GET ${explorerApiUrl}/workers/durable_objects/namespaces - Durable Object namespaces`,
` GET ${explorerApiUrl}/workflows - Workflows`,
` POST ${explorerApiUrl}/local/observability/query - run a read-only SQL query (SELECT/WITH only) over captured request traces and console logs. Tables: spans, logs (read attributes via json(attributes)). Example:`,
` curl -X POST ${explorerApiUrl}/local/observability/query -H 'Content-Type: application/json' -d '{"sql":"SELECT service, name, outcome, duration_ms FROM spans WHERE parent_id IS NULL LIMIT 20"}'`,
`If the routes above don't cover what you need, fetch the full OpenAPI schema (large - use only as a last resort):`,
` GET ${explorerApiUrl} - OpenAPI schema`,
"",
].join("\n")
);
}
Comment thread
dario-piotrowicz marked this conversation as resolved.
4 changes: 4 additions & 0 deletions packages/wrangler/src/dev/start-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,10 @@ function maybePrintScheduledWorkerWarning(
);
}

/**
* Keep the message in sync with the Vite plugin copy in
* packages/vite-plugin-cloudflare/src/plugins/agent-hint.ts.
*/
function printLocalExplorerAgentHint(url: URL): void {
const displayUrl = new URL(url.href);
displayUrl.hostname = formatHostname(url.hostname);
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading