-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(vite-plugin): surface Local Explorer API to headless agents- #14688 #14912
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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); | ||
| }); | ||
| }); |
| 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 }); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
Comment on lines
+16
to
+22
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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") | ||
| ); | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
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 })atpackages/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 passesprocessAncestry: NO_PROCESS_ANCESTRY(an empty array) todetectAgenticEnvironment, 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 theprocessAncestry: []option, soam-i-vibingfalls back to its default process-tree traversal (synchronousexecSync('ps ...')). This path is only reached in non-TTY sessions (the guardprocess.stdin.isTTY || ... || !isAgentSession()inpackages/vite-plugin-cloudflare/src/plugins/agent-hint.ts:38-44short-circuits beforeisAgentSession()for TTYs), i.e. exactly the CI / agent environments the Wrangler comment warns about.Align with the Wrangler wrapper by passing an empty
processAncestryarray.Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah yeah definitely! 👍