Skip to content
Merged
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
72 changes: 71 additions & 1 deletion packages/cli/src/commands/playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,54 @@ function openBrowser(url: string): void {
}
}

interface ExistingPlayground {
version: string;
pid: number;
}

/**
* Probe the target port for an already-running playground instance.
* Returns version + pid when the enriched `/health` response is present,
* `null` when the port is free or occupied by a non-playground process.
*/
async function probeExistingPlayground(port: number): Promise<ExistingPlayground | null> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
clearTimeout(timeout);
if (!res.ok) return null;
const body = (await res.json()) as { playground?: { version?: string; pid?: number } };
if (body.playground?.pid) {
return { version: body.playground.version ?? "unknown", pid: body.playground.pid };
}
} catch {
// port not listening or not a playground — ignore
}
return null;
}

/**
* Kill an existing playground process (best-effort, SIGTERM) and wait briefly for the port to free.
* Returns true when the port appears released; false on timeout.
*/
async function replaceExistingPlayground(existing: ExistingPlayground, port: number): Promise<boolean> {
log.info(`Replacing playground v${existing.version} (pid ${existing.pid}) on port ${port}...`);
try {
process.kill(existing.pid, "SIGTERM");
} catch {
// already gone — that's fine
return true;
}
// Wait up to 3 s for the port to free (100 ms poll).
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 100));
const still = await probeExistingPlayground(port);
if (!still) return true;
}
return false;
}

export async function playgroundCommand(options: PlaygroundOptions): Promise<void> {
const port = options.port ? Number(options.port) : DEFAULT_PORT;
if (!Number.isInteger(port) || port <= 0) {
Expand All @@ -143,14 +191,36 @@ export async function playgroundCommand(options: PlaygroundOptions): Promise<voi
throw new Error(`Playground supports providers: ${supported}; received '${options.provider}'.`);
}

// --- Detect and replace stale playground on the target port ----
const version = cliVersion();
const existing = await probeExistingPlayground(port);
if (existing) {
if (existing.version === version) {
// Same version already running — just reuse it.
const url = `http://localhost:${port}`;
log.success(`Playground v${version} already running at ${url} (pid ${existing.pid})`);
if (options.open !== false) openBrowser(url);
return;
}
// Different version — replace the old instance so users always get the matching UI.
const freed = await replaceExistingPlayground(existing, port);
if (!freed) {
log.warn(
`Could not stop existing playground (pid ${existing.pid}) on port ${port}. ` +
`Kill it manually and retry, or use --port to pick another port.`,
);
return;
}
}

const env: NodeJS.ProcessEnv = { ...process.env, PORT: String(port) };
if (options.provider) {
// AGENTS_CLI_PROVIDER 在 playground bootstrap(config.json force)之后写回,保证 CLI 显式指定优先生效。
env.AGENTS_PROVIDER = options.provider;
env.AGENTS_CLI_PROVIDER = options.provider;
}

const { cmd, args } = resolveLauncher(cliVersion());
const { cmd, args } = resolveLauncher(version);
if (cmd === "npx") log.info(`Fetching ${PLAYGROUND_PKG} (first run may take a moment)...`);

const child = spawn(cmd, args, { env, stdio: ["inherit", "pipe", "inherit"] });
Expand Down
28 changes: 26 additions & 2 deletions packages/playground/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,22 @@ export const DEFAULT_PLAYGROUND_PORT = 4848;

// Bundled webui build lives at <package>/web. This file ships to dist/bin/playground.js,
// so the package root is two levels up from the emitted bundle.
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "../../web");
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
const webRoot = join(pkgRoot, "web");
const indexHtml = injectPlaygroundRuntimeMarker(readFileSync(join(webRoot, "index.html"), "utf8"));

/** Read the playground package version once at startup (works for both source and published dist). */
function readPlaygroundVersion(): string {
try {
const manifest = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8")) as { version?: string };
return manifest.version ?? "unknown";
} catch {
return "unknown";
}
}

const playgroundVersion = readPlaygroundVersion();

function injectPlaygroundRuntimeMarker(html: string): string {
if (html.includes('name="agents-runtime"')) return html;
return html.replace("<head>", '<head>\n <meta name="agents-runtime" content="playground" />');
Expand All @@ -38,12 +51,23 @@ export async function startServer(): Promise<void> {
}

const root = new Hono();
// Server routes: /api/*, /health, /openapi.json (merged into the router).
// Playground-enriched health check — registered before the server routes so it takes
// precedence over the plain { status: "ok" } from apps/server. The `playground` field
// lets the CLI detect a stale instance on the target port and replace it automatically.
root.get("/health", (c) => c.json({ status: "ok", playground: { version: playgroundVersion, pid: process.pid } }));
// Server routes: /api/*, /openapi.json (merged into the router).
root.route("/", app);
// Always serve the injected shell for document routes — static middleware would otherwise
// return web/index.html without the playground runtime marker.
root.get("/", (c) => c.html(indexHtml));
root.get("/index.html", (c) => c.html(indexHtml));
// Static assets — bust browser caches across playground upgrades. The filenames are
// stable (`assets/index.js`, not hashed) because the console embed requires predictable
// paths, so we instruct browsers to revalidate on every navigation.
root.use("/assets/*", async (c, next) => {
await next();
c.header("Cache-Control", "no-cache");
});
root.use("/assets/*", serveStatic({ root: webRoot }));
// SPA fallback: any unmatched GET renders the shell so client routing works on reload.
root.get("*", (c) => c.html(indexHtml));
Expand Down