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
5 changes: 5 additions & 0 deletions .changeset/plain-cli-context-providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Mount conventional request context providers for plain `.ts` routed CLI commands, so `(await agent()).providers` carries the same values on every generated request scope (MCP, events, rendered CLI, rendered scripts, and now plain CLI) with identical ordering, cancellation, and fail-closed semantics; the plain execute context also exposes the consumed `args`. The rendered-session bridge now forwards the invocation to its react-server worker, so providers behind rendered CLI commands and rendered scripts observe the real `invocation.kind` instead of `undefined`.
20 changes: 12 additions & 8 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model.
| `src/scripts/<name>.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/<name>.mjs` plus a `scripts/<name>-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry |
| `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as `<bin> library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` |
| `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` |
| `src/providers/<name>.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal }`; its value is mounted at `(await agent()).providers.<camelCaseName>` for generated MCP and event routes, projected MCP commands, rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` |
| `src/providers/<name>.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal }`; its value is mounted at `(await agent()).providers.<camelCaseName>` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` |

Route and package entry conventions match `.ts` and `.tsx` files exactly;
the state convention is specifically `src/state.ts`.
Expand Down Expand Up @@ -141,13 +141,17 @@ with the contract `(context: { invocation, signal }) => value |
Promise<value>`, where `invocation` is the current route invocation and
`signal` is its request abort signal.

The generated shared Flight worker executes providers once per request,
sequentially in deterministic key order, before entering `runAgentRequest`.
The returned values join the request's provider map. A thrown or rejected
factory fails the request closed; expected degradation should return an honest
unavailable-shaped value instead of throwing. `processLifetime` is reserved
for the framework-owned process identity and hit counter, so provider filenames
must not derive that key.
Every generated request scope — the shared Flight worker behind generated MCP
and event routes, the react-server worker behind rendered routed CLI commands
and rendered scripts, and the routed-CLI executable itself for plain `.ts`
commands — executes providers once per request, sequentially in deterministic
key order, before entering `runAgentRequest`. The returned values join the
request's provider map. A thrown or rejected factory fails the request closed;
expected degradation should return an honest unavailable-shaped value instead
of throwing. `invocation.kind` stays surface-specific (`tool`, `event`, `cli`,
`script`), so a provider can branch on the entry surface deliberately.
`processLifetime` is reserved for the framework-owned process identity and hit
counter, so provider filenames must not derive that key.

### Handler request context

Expand Down
120 changes: 68 additions & 52 deletions packages/agent-bundle/src/build/entry-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ export const generatedInstallBinEntrySource = (options: {
export interface GeneratedCliBinEntryOptions {
readonly commands: readonly CompiledCliCommand[];
readonly plugin: { readonly description?: string; readonly name: string; readonly version: string };
/** Conventional request context providers, mounted for plain commands in this process (#313). */
readonly providers?: readonly CompiledProvider[];
readonly routes: readonly CompiledAgentRoute[];
readonly state?: NormalizedStateDefinition;
/** The sibling react-server worker bundle; required when any command is rendered. */
Expand Down Expand Up @@ -220,7 +222,9 @@ const renderedSessionSource = (workerFile: string): readonly string[] => [
' pending.set(id, entry);',
" dispatch.signal.addEventListener('abort', entry.abort, { once: true });",
' if (dispatch.signal.aborted) { entry.abort(); return stream; }',
" worker.postMessage({ id, props, request, routeId, type: 'render' });",
// The invocation rides to the worker so conventional providers observe the
// real surface (`cli`, `script`, `tool`) instead of an undefined invocation.
" worker.postMessage({ id, invocation, props, request, routeId, type: 'render' });",
' return stream;',
' },',
' });',
Expand Down Expand Up @@ -250,6 +254,8 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions)
if (rendered && options.workerFile === undefined) {
throw new Error('A generated CLI with rendered commands requires a worker file.');
}
const providers = orderedProviders(options.providers ?? []);
const plainIndent = options.state === undefined ? ' ' : ' ';
return [
`import { CliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`,
rendered
Expand All @@ -258,8 +264,11 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions)
...(rendered ? ["import { Worker } from 'node:worker_threads';"] : []),
...generatedStateImports(options.state, 'cwd'),
...routeImports(commandRoutes),
...providerImports(providers),
'',
...generatedStateOwner(options.state, 'cwd'),
'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };',
...providerRegistrySource(providers),
'const routes = Object.freeze({',
...commandRoutes.map((route, index) =>
` ${JSON.stringify(route.id)}: Object.freeze({ module: route${String(index)} }),`),
Expand All @@ -275,15 +284,24 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions)
' }',
'};',
'',
// Plain commands mount the same conventional providers as every other
// generated request scope (#313): once per request, in deterministic key
// order, fail-closed, before the typed Agent request context opens.
'const execute = async (command, input, context) => {',
' const route = routes[command.routeId];',
" if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated CLI route must default-export an async function.');",
' const parsed = parseInput(route, input);',
' const cwd = process.cwd();',
' processLifetime.hits += 1;',
...(options.state === undefined
? []
: [' const bindings = await runtimeState.requestBindings({ signal: context.signal });', ' try {']),
`${options.state === undefined ? ' ' : ' '}const result = await runAgentRequest({`,
...providerExecutionSource(providers, {
indent: plainIndent,
invocation: "{ kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }",
signal: 'context.signal',
}),
`${plainIndent}const result = await runAgentRequest({`,
' capabilities: {',
' command: unavailable(),',
' filesystem: unavailable(),',
Expand All @@ -293,6 +311,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions)
" host: unavailable('unsupported-surface'),",
" invocation: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') },",
...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']),
` providers: ${providerValuesExpression(providers)},`,
' signal: context.signal,',
...(options.state === undefined ? [] : [' state: bindings.state,']),
" workspace: available({ root: cwd }, 'derived'),",
Expand Down Expand Up @@ -380,13 +399,7 @@ export const generatedRenderedRouteWorkerSource = (
'// Machine output owns the parent stdout; anything a route logs goes to stderr.',
'process.stdout.write = process.stderr.write.bind(process.stderr);',
'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };',
...(providers.length === 0
? []
: [
'const providers = Object.freeze([',
...providerRecords(providers),
']);',
]),
...providerRegistrySource(providers),
'const routes = Object.freeze({',
...options.routes.map((route, index) =>
` ${JSON.stringify(route.id)}: Object.freeze({ module: route${String(index)} }),`),
Expand All @@ -405,21 +418,7 @@ export const generatedRenderedRouteWorkerSource = (
? []
: [' const bindings = await runtimeState.requestBindings({ signal: controller.signal });']),
...(options.state === undefined ? [] : [' try {']),
...(providers.length === 0
? []
: [
' const providerValues = { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } };',
' for (const provider of providers) {',
' if (typeof provider.module.default !== \'function\') {',
' throw new TypeError(`Context provider "${provider.key}" (${provider.source}) must default-export a factory.`);',
' }',
' try {',
' providerValues[provider.key] = await provider.module.default({ invocation: message.invocation, signal: controller.signal });',
' } catch (error) {',
' throw new Error(`Context provider "${provider.key}" (${provider.source}) failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });',
' }',
' }',
]),
...providerExecutionSource(providers, { indent: ' ', invocation: 'message.invocation', signal: 'controller.signal' }),
' await runAgentRequest({',
' capabilities: {',
' command: unavailable(),',
Expand All @@ -431,9 +430,7 @@ export const generatedRenderedRouteWorkerSource = (
' invocation: message.request,',
...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']),
" progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: 'progress', update }); } },",
...(providers.length === 0
? [' providers: { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } },']
: [' providers: providerValues,']),
` providers: ${providerValuesExpression(providers)},`,
' signal: controller.signal,',
...(options.state === undefined ? [] : [' state: bindings.state,']),
" workspace: available({ root: cwd }, 'derived'),",
Expand Down Expand Up @@ -577,6 +574,47 @@ const providerRecords = (providers: readonly CompiledProvider[]): readonly strin
providers.map((provider, index) =>
` Object.freeze({ key: ${JSON.stringify(providerKeyFromName(provider.name))}, module: provider${String(index)}, source: ${JSON.stringify(provider.provenance.relativePath)} }),`);

/** The frozen provider registry a generated request scope iterates; empty when the project declares none. */
const providerRegistrySource = (providers: readonly CompiledProvider[]): readonly string[] =>
providers.length === 0
? []
: ['const providers = Object.freeze([', ...providerRecords(providers), ']);'];

const processLifetimeValueSource =
'{ hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid }';

/**
* Per-request provider execution shared by every generated request scope
* (shared Flight worker, rendered CLI/script worker, plain routed CLI): once
* per request, sequentially in deterministic key order, fail-closed on a
* missing factory or a thrown/rejected factory, with the framework-owned
* `processLifetime` value seeded first.
*/
const providerExecutionSource = (
providers: readonly CompiledProvider[],
expressions: { readonly indent: string; readonly invocation: string; readonly signal: string },
): readonly string[] => {
if (providers.length === 0) return [];
const { indent, invocation, signal } = expressions;
return [
`${indent}const providerValues = { processLifetime: ${processLifetimeValueSource} };`,
`${indent}for (const provider of providers) {`,
`${indent} if (typeof provider.module.default !== 'function') {`,
`${indent} throw new TypeError(\`Context provider "\${provider.key}" (\${provider.source}) must default-export a factory.\`);`,
`${indent} }`,
`${indent} try {`,
`${indent} providerValues[provider.key] = await provider.module.default({ invocation: ${invocation}, signal: ${signal} });`,
`${indent} } catch (error) {`,
`${indent} throw new Error(\`Context provider "\${provider.key}" (\${provider.source}) failed: \${error instanceof Error ? error.message : String(error)}\`, { cause: error });`,
`${indent} }`,
`${indent}}`,
];
};

/** The `providers` request-scope value: the executed map, or only the framework-owned process identity. */
const providerValuesExpression = (providers: readonly CompiledProvider[]): string =>
providers.length === 0 ? `{ processLifetime: ${processLifetimeValueSource} }` : 'providerValues';

/** The long-lived react-server worker used by one generated MCP process. */
export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWorkerOptions): string => {
const routes = executableMcpRoutes(options.routes);
Expand All @@ -600,13 +638,7 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo
`const ARTIFACT_EPOCH = ${JSON.stringify(options.artifactEpoch)};`,
'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };',
...generatedStateOwner(options.state, 'artifact'),
...(providers.length === 0
? []
: [
'const providers = Object.freeze([',
...providerRecords(providers),
']);',
]),
...providerRegistrySource(providers),
'const routes = Object.freeze({',
...routeRecords(routes),
...noticeInboxRecord(options.state),
Expand All @@ -629,30 +661,14 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo
...(options.state === undefined
? []
: [' const bindings = await runtimeState.requestBindings({ signal: controller.signal });', ' try {']),
...(providers.length === 0
? []
: [
' const providerValues = { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } };',
' for (const provider of providers) {',
' if (typeof provider.module.default !== \'function\') {',
' throw new TypeError(`Context provider "${provider.key}" (${provider.source}) must default-export a factory.`);',
' }',
' try {',
' providerValues[provider.key] = await provider.module.default({ invocation: message.invocation, signal: controller.signal });',
' } catch (error) {',
' throw new Error(`Context provider "${provider.key}" (${provider.source}) failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });',
' }',
' }',
]),
...providerExecutionSource(providers, { indent: ' ', invocation: 'message.invocation', signal: 'controller.signal' }),
' const bytes = await runAgentRequest({',
' ...(message.actor === undefined ? {} : { actor: message.actor }),',
' ...(message.host === undefined ? {} : { host: message.host }),',
' invocation: { ...message.requestInvocation, artifactEpoch: ARTIFACT_EPOCH, kind: message.invocation.kind, operationId: route.id, surface: route.name },',
...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']),
' progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: \'progress\', update }); } },',
...(providers.length === 0
? [' providers: { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } },']
: [' providers: providerValues,']),
` providers: ${providerValuesExpression(providers)},`,
' ...(message.session === undefined ? {} : { session: message.session }),',
' signal: controller.signal,',
...(options.state === undefined ? [] : [' state: bindings.state,']),
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/build/package-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export const planPackageEntries = async (
name: model.metadata.name,
version: model.metadata.version,
},
providers: model.providers ?? [],
routes: bin.generatedCli.routes,
...(model.state === undefined ? {} : { state: model.state }),
...(rendered ? { workerFile } : {}),
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-bundle/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export class CliInputError extends Error {
}

export interface GeneratedCliExecuteContext {
/** The raw argv the command consumed, for the provider invocation's `args`. */
readonly args: readonly string[];
/** True when `--json` was passed; plain commands already emit canonical JSON. */
readonly json: boolean;
readonly signal: AbortSignal;
Expand Down Expand Up @@ -669,7 +671,7 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro
}
}
if (parsed.ndjson) throw new CliUsageError('--ndjson requires a rendered command.');
const result = await options.execute(command, parsed.input, { json: parsed.json, signal });
const result = await options.execute(command, parsed.input, { args: rest, json: parsed.json, signal });
signal.throwIfAborted();
const exitCode = resultExitCode(command.exitCode, result);
writeOut(`${stableJson(result === undefined ? null : result)}\n`);
Expand Down
Loading
Loading