Skip to content
Closed
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 @@ -74,6 +74,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
122 changes: 122 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,122 @@
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(
"This dev session seems to be running in an AI agent."
);
expect(output).toContain(
"The Local Explorer API is available at http://localhost:5173/cdn-cgi/explorer/api"
);
expect(output).toContain(
"GET http://localhost:5173/cdn-cgi/explorer/api/local/workers - local Workers and bindings"
);
});

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(
"This preview session seems to be 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);
});
});
22 changes: 22 additions & 0 deletions packages/vite-plugin-cloudflare/src/detect-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// 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 { isAgent } from "am-i-vibing";

/**
* Detects whether the current process is being driven by an AI coding agent.
*
* Returns `true` only when the detected environment type is exactly `"agent"`,
* NOT `"hybrid"` or `"interactive"`. Hybrid terminals (such as Warp or VS Code)
* embed agentic features but are still driven by a human at the keyboard, so
* they should behave like a regular interactive session.
*
* Any error resolves to `false` rather than propagating.
*
* @returns Whether the session is driven by a headless AI agent
*/
export function isAgentSession(): boolean {
try {
return isAgent({ env: process.env });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Agent detection runs a slow blocking process scan on every non-interactive server start

Agent detection is invoked (isAgent({ env: process.env }) at packages/vite-plugin-cloudflare/src/detect-agent.ts:18) without disabling the process-tree scan, so every non-interactive dev/preview start performs a slow blocking system call that can stall or time out.
Impact: Starting a Vite dev or preview server in CI or an agent environment can hang or be noticeably slower.

Process ancestry traversal via execSync not disabled

The sibling implementation in Wrangler (packages/wrangler/src/utils/detect-agent.ts:1-47) deliberately passes processAncestry: NO_PROCESS_ANCESTRY (an empty array) to detectAgenticEnvironment, with an explicit comment: "Process tree traversal uses execSync('ps ...') which is slow and can cause timeouts, especially in CI environments. Environment variable detection is sufficient for identifying most agentic environments."

The new wrapper here calls isAgent({ env: process.env }) and omits the processAncestry: [] option, so am-i-vibing falls back to its default process-tree traversal (synchronous execSync('ps ...')). This path is only reached in non-TTY sessions (the guard process.stdin.isTTY || ... || !isAgentSession() in packages/vite-plugin-cloudflare/src/plugins/agent-hint.ts:38-44 short-circuits before isAgentSession() for TTYs), i.e. exactly the CI / agent environments the Wrangler comment warns about.

Align with the Wrangler wrapper by passing an empty processAncestry array.

Suggested change
return isAgent({ env: process.env });
// Pass an empty processAncestry to skip process-tree traversal, which
// uses execSync('ps ...') and can be slow / time out in CI. Environment
// variable detection is sufficient for most agentic environments.
return isAgent({ env: process.env, processAncestry: [] });
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@dario-piotrowicz What do you think about this comment? Would it be better to align with the Wrangler implementation?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

ah yeah definitely! 👍

} catch {
return false;
}
}
Comment on lines +16 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 isAgentSession relies on am-i-vibing's isAgent returning true only for type 'agent'

The doc comment on isAgentSession claims it returns true only when the environment type is exactly "agent" (not "hybrid"/"interactive"). This depends on am-i-vibing's isAgent() helper being defined as type === "agent". The sibling Wrangler wrapper instead calls detectAgenticEnvironment(...).type === "agent" explicitly (packages/wrangler/src/utils/detect-agent.ts:42). If isAgent() in am-i-vibing were ever to include hybrid (e.g. an alias of isAgentic), this wrapper's contract would silently break, whereas Wrangler's explicit check would not. Worth a quick confirmation against the installed am-i-vibing@0.5.0 API.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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
89 changes: 89 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,89 @@
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);
}
};
Comment on lines +46 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📝 Info: bindCLIShortcuts patching coexists with shortcutsPlugin only because shortcuts bail in non-TTY

agentHintPlugin patches server.bindCLIShortcuts in configureServer/configurePreviewServer, and so does shortcutsPlugin (packages/vite-plugin-cloudflare/src/plugins/shortcuts.ts:119-161). These do not conflict because maybeAddAgentHint only patches when !process.stdin.isTTY (packages/vite-plugin-cloudflare/src/plugins/agent-hint.ts:38-44), while addShortcuts returns early when !process.stdin.isTTY (shortcuts.ts:44-46). So the two patches are mutually exclusive by TTY state, and the hint relies on cf-vite.ts:192 (and Vite's CLI) calling bindCLIShortcuts({ print: true }) unconditionally. This coupling is subtle: if either the TTY guard in shortcuts or the unconditional bindCLIShortcuts({print:true}) call changes, behavior could break or double-patch.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

/**
* Prints the Local Explorer API URL and useful routes to stdout so that
* headless AI agents can discover and call them programmatically.
*
* @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(
[
"",
`This ${mode} session seems to be running in an AI agent.`,
`The Local Explorer API is available at ${explorerApiUrl}`,
`Useful routes:`,
` GET ${explorerApiUrl} - OpenAPI schema`,
` GET ${explorerApiUrl}/d1/database - D1 databases`,
` GET ${explorerApiUrl}/local/workers - local Workers and bindings`,
` GET ${explorerApiUrl}/r2/buckets - R2 buckets`,
` GET ${explorerApiUrl}/storage/kv/namespaces - KV namespaces`,
` GET ${explorerApiUrl}/workers/durable_objects/namespaces - Durable Object namespaces`,
` GET ${explorerApiUrl}/workflows - Workflows`,
"",
].join("\n")
);
}
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