Skip to content

feat(serve-app): add agent-bundle/serve-app-command and AB4837 for compiler imports in routed executables (#558) - #582

Merged
ScriptedAlchemy merged 8 commits into
mainfrom
feat/558-serve-app-command
Sep 5, 2026
Merged

feat(serve-app): add agent-bundle/serve-app-command and AB4837 for compiler imports in routed executables (#558)#582
ScriptedAlchemy merged 8 commits into
mainfrom
feat/558-serve-app-command

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Closes #558

What lands

Issue #558's recommendation (c) plus the named diagnostic. Option (b) — a bundleable host inside the artifact — is superseded by #564 (a production web surface) and is not built here; the helper is shaped so #564's generated <plugin> web command can reuse it (spawnServeApp takes every argv-form option and an injectable cli, relay, signal).

agent-bundle/serve-app-command — a dependency-free leaf entry

packages/agent-bundle/src/serve-app-command.ts, declared in package.json exports and built by rslib.config.ts exactly like agent-bundle/launch-env / agent-bundle/terminal-capability: plain Node built-ins only, so the bundler inlines it into every self-contained generated executable (no autoExternal, no externals; AB6005 stays satisfied — the packed proof asserts no live agent-bundle import survives in the bin).

  • spawnServeApp(options) — lowers the options to agent-bundle serve-app argv, resolves the framework CLI installed at or above root, spawns node <bin> serve-app … with stdio: ['ignore', 'pipe', 'inherit'], relays every stdout line to this process's stderr (or a relay callback) so the routed command keeps stdout for its JSON result, and resolves once the CLI prints its ready line with { app, url, port, tool, server, pid, closed, close() }. The route's signal turns into the child's SIGTERM; close() does the same and awaits the exit.
  • serveAppArgv(options) — the exhaustive lowering: a { [K in keyof ServeAppArgvOptions]-?: string[] } record, so a new option fails to compile until it is lowered.
  • locateFrameworkCli(root)createRequire(<root>/package.json).resolve('agent-bundle/package.json') (honours hoisting and pnpm's layout; build: gate releases on attw + declaration-import check; manifest hygiene for agent-bundle, rsc-markdown-stream, create-agent-bundle (#566 §2) #568's ./package.json export makes this the primary path), falling back to the ancestor node_modules walk when a package's exports hides its manifest; reads bin. Shares src/core/dependency-manifest.ts with the build's dependency-root discovery (src/build/rslib.ts was rewired onto it in the same change — no second copy).
  • ServeAppCommandError (CodedError) with codeframework-not-installed | artifact-missing | spawn-failed | exited-before-ready (carries exit: { code, signal }) | aborted | stop-failed (the running child refused SIGTERM, e.g. EPERM; thrown by close(), or the rejection of a pre-ready abort — the child is still running and closed still settles only on its real exit), each with an actionable message. A kill() failure is never mistaken for an exit: closed is tied to the child's close event alone.
  • parseServeAppReadyLine / formatServeAppReadyLine / serveAppAllowCapabilities live in src/serve-app/command-contract.ts; cli.ts now prints the ready line and validates --allow through the same module, so the CLI and the parser cannot drift.

ServeAppArgvOptions = every key of ServeAppOptions (agent-bundle/api) except the host-process-only logger, registry, openBrowser, targets, timeoutMs; autoApprove is narrowed to the four capabilities --allow actually accepts (call-tool, download-file, open-external-link, request-display-mode).

AB4837 — the compiler imported into a generated executable

packages/agent-bundle/src/routes/framework-imports.ts, wired into compileRouteGraph (src/routes/graph.ts), so inspect, validate, build, and dev all report it before the bundler runs. A route module of a generated server or CLI, a script, an event route — or a module one of them reaches through relative value imports — that value-imports agent-bundle, agent-bundle/api, agent-bundle/config, agent-bundle/eval, agent-bundle/rstest, agent-bundle/test, or agent-bundle/test/browser gets one error naming the file, the specifier, the executable it would be inlined into, and the helper. Layouts are judged only when a rendered route the build inlines them into exists (a generated server's route, a rendered CLI command, a rendered script — the same selection build/cli-bins.ts makes); providers only where a generated executable mounts them (a generated server, a generated CLI, a rendered script, an event route — not a plain .ts script, which is bundled from its own source). The scanner resolves each identifier through the TypeScript binder (ts.Program + TypeChecker), so import type, type-qualified specifiers, bindings used only in type positions, and imported names shadowed by a local declaration are not reported (SWC elides them); routes of a custom/command/remote server or a conventional CLI are never bundled and are not judged; App routes are browser builds and are exempt.

Allocated in the AB48xx route-graph range (next free after AB4836) rather than AB6xxx: it is a route-graph judgment made at config inspection, before any artifact exists — the AB6xxx codes are post-build artifact-module checks. docs/diagnostics.md carries the row (the reference pages render from it).

Diagnostic sample (from tests/cli-routes-build.test.ts):

AB4837 error src/cli/dashboard.ts
Route module src/cli/dashboard.ts imports "agent-bundle/api" as a value; the routed CLI executable is self-contained and cannot bundle the compiler, so the build would fail deep inside the generated executable (an unresolvable compiler module or AB6005) instead of at this import.
recovery: Keep framework calls in a host process: serve an MCP App from a routed command with spawnServeApp from agent-bundle/serve-app-command, which spawns agent-bundle serve-app; use import type for framework types; otherwise move the call into a package.json script or a hand-written .mjs run from the checkout.

Docs and changeset

  • website/docs/{en,zh}/guide/authoring/mcp.mdx "Serving an App standalone": the hauler dashboard sample now uses spawnServeApp; the host-process note explains AB4837.
  • website/docs/{en,zh}/reference/api.mdx (new entry row), reference/cli.mdx (the serve-app ready-line/exit contract), guide/distribution/validation.mdx (AB4837 next to AB6005); website/rspress.config.ts adds the entry to TypeDoc.
  • docs/entry-conventions.md, docs/framework-mode.md, docs/diagnostics.md.
  • .changeset/558-serve-app-command.md (agent-bundle: patch).

Proofs

Argv round-trip (tests/serve-app-command.test.ts, unit pool): a { [K in keyof ServeAppOptions]-?: 'argv' | 'host-only' } classification record and Equals<> type assertions make a new ServeAppOptions key a compile error until it is lowered or declared host-only; a Required<ServeAppArgvOptions> sample is lowered and parsed back through the real serve-app CLI parser (runCli with serveApp recorded), including --no-env / --no-open, the CLI defaults for a minimal option set, and the --env-file/--no-env conflict. locateFrameworkCli is covered for object/string bin, a manifest two levels above the root, an exports-hidden manifest, a bin-less manifest, and this checkout's own bin.

Spawn (tests/serve-app-command-spawn.test.ts, integration pool, 13 tests against a fake CLI and the real one): ready-line relay and resolution, SIGTERM close, exited-before-ready with the exit, aborted before/after the ready line, when already aborted, and when the abort lands during the artifact check (the post-spawn signal.aborted re-check), framework-not-installed, artifact-missing, spawn-failed (both throw and error event), a post-spawn error that keeps closed pending until the real exit and is carried as the cause if the child then exits before ready, stop-failed from close(), from a pre-ready abort, and from an abort re-checked synchronously after the spawn (a fake kill that emits error EPERM and returns false, exactly as Node does; the child is asserted alive, then torn down), and the real CLI failing fast on a missing manifest classified as exited-before-ready.

Packed (tests/packed-serve-app-command.test.ts, pnpm test:packed, 4 tests): fixtures/serve-app-command (one server, one App, one routed dashboard command using the helper) is copied into a scratch consumer, the packed agent-bundle tarball is npm installed, agent-bundle build runs, and both generated bins (dist/bin/<plugin>.js and artifact/portable/bin/<plugin>.mjs) are spawned as OS processes: no live agent-bundle import in the bin bytes; the JSON result on stdout carries the URL (http://127.0.0.1:<port>/), the child pid, and probeStatus: 200, the ready line is on stderr; after the run the child pid is ESRCH and the URL refuses connections; SIGINT to the bin exits 130 with the served App torn down (child and its packed MCP server both gone); artifact-missing, framework-not-installed, and exited-before-ready (the child's AB6000 on stderr) each come back as the result document.

Diagnostic (tests/cli-routes-build.test.ts, integration pool): a routed command with await import('agent-bundle/api') is reported by validate without a build and rejects build with DiagnosticError carrying exactly one AB4837, no Can't resolve and no AB6005, and neither dist/ nor artifact/ is written. On main today the same fixture fails inside the bundler.

Gates run on the final branch state (after merging origin/main at #568, and again after each self-review fix): pnpm build, pnpm typecheck, pnpm lint, pnpm lint:release, pnpm test:unit, pnpm test:route-unit, pnpm test:projection, the integration files above, pnpm test:packed packages/agent-bundle/tests/packed-serve-app-command.test.ts, pnpm docs:site:build (en/zh parity).

Self-review

Reviewer: a read-only reviewer subagent on gpt-5.6-sol-medium (TraceDecay was unavailable through both MCP and CLI during the review, so it worked from the diff and read-only source inspection), asked for concrete merge risks only — bugs, breaking changes, missed tests, doc or changeset gaps — against the diff vs origin/main; four rounds, each re-run after the fixes the previous one asked for.

# Finding Disposition
1 routes/framework-imports.ts — binding detection compared identifier text globally and ignored lexical shadowing: a type-only import shadowed by a same-named parameter used at runtime was a false AB4837. Reproduced. Fixed. The scanner now resolves every identifier through the TypeScript binder (ts.Program + TypeChecker) to the import it actually refers to. Shadowing, namespace/default imports, JSX, decorators, re-exports, labels, import.meta, and .js/.mjs modules are covered in tests/route-framework-imports.test.ts. Confirmed resolved in round 2.
2 routes/graph.ts — every root layout was scanned unconditionally, but layouts are inlined only into rendered routes, and (round 2) plain CLI routes and plain scripts do not import layouts, plain scripts do not mount providers either: valid projects could get a false build-blocking AB4837. Fixed. Layouts are judged from the exact set the build inlines them into (generated-server routes, rendered CLI commands via cli.commands, isRenderedScriptRoute scripts); providers where a generated executable mounts them (generated server, generated CLI, rendered script, event route) and not for plain .ts scripts. tests/route-graph.test.ts gained App-only, plain-CLI, rendered-CLI, plain-script, and rendered-script cases; docs/diagnostics.md row reworded. Confirmed resolved in round 3.
3 serve-app-command.ts — a child error after readiness (a failed kill()) called finish() with a synthetic exit, so closed resolved while the process was still alive; (round 2) merely storing the error instead left spawnServeApp()/close() hanging on a refused kill(); (round 3) the already-aborted re-check called stop() before the child's error listener existed, so a synchronous EPERM from kill() rejected with an untyped error and leaked the child. All three reproduced. Fixed across three commits. closed settles only on the real close event; a post-spawn error is kept as the cause should the child then exit before ready; a refused kill() is the new stop-failed code, thrown by close() and the rejection of a pre-ready abort; the child's data/spawn/error/close listeners are attached before the AbortSignal is registered or re-checked, and an error emitted while stop() runs is classified as the stop failure ahead of the not-yet-fired spawn flag. Each path has an integration test that failed on the preceding commit. Confirmed resolved in round 4.
4 .changeset/558-serve-app-command.md ended with the issue number (#558) instead of the PR (#582). Fixed. Confirmed resolved in round 2.

Round 4: "No remaining concrete merge risks found. Recommendation: merge."

@changeset-bot

changeset-bot Bot commented Sep 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c7afff7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agent-bundle Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-05T02:15:20.626043Z f261ee5 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 5, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@582
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@582
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/rsc-markdown-stream@582
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@582

commit: c7afff7

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f261ee5291

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/agent-bundle/src/routes/graph.ts Outdated
…uts/providers only when bundled; keep closed pending through post-spawn errors; changeset names the PR
…uses the signal; judge layouts only for rendered routes and providers only where mounted
…dy-aborted re-check so a refused kill() is stop-failed, not an unhandled error
@ScriptedAlchemy
ScriptedAlchemy enabled auto-merge (squash) September 5, 2026 03:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Let a plugin CLI route serve its own MCP App without bundling the framework

1 participant