diff --git a/.changeset/511-terminal-capability.md b/.changeset/511-terminal-capability.md new file mode 100644 index 000000000..970970eb8 --- /dev/null +++ b/.changeset/511-terminal-capability.md @@ -0,0 +1,6 @@ +--- +'agent-bundle': patch +'@agent-bundle/runtime': patch +--- + +Expose the process's terminal capability to routes and scripts as `(await agent()).terminal`, so a plugin that paints its own stderr or sizes its own output no longer probes `process.stdout.isTTY`, `columns`, or `FORCE_COLOR` itself. The new `Observed` axis reports `hostSurface` (`cli`, `mcp`, `hook`, `script`, `workbench`), a `stdout` and `stderr` stream each with `kind` (`tty`, `pipe`, `none`), `color` (`none`, `basic`, `256`, `truecolor`), and `columns`/`rows` when known, plus `sharesTarget` (fd 1 and fd 2 name one file). Routed CLI executables (plain, rendered, and projected MCP commands) and rendered scripts probe their process once — honouring `FORCE_COLOR`, `CLICOLOR_FORCE`, `NO_COLOR`, `CLICOLOR=0`, `TERM=dumb`, `COLORTERM`/`TERM` depth, and `COLUMNS`/`LINES` overrides — and select their `tty` or piped output mode from that same value; generated MCP servers, event routes, and Workbench replays report `none` on both streams and never guess. The executable envelope passes the same value to plain `main` scripts and bins as `main(argv, { terminal })` (`ExecutableMainContext` from `agent-bundle`); a one-parameter `main` keeps working. `runGeneratedCliEntry` and `runGeneratedRenderedScript` (`agent-bundle/cli-entry`) accept `terminal` and hand it to `execute`, `render`, and `createSession`; `runRscCli` accepts `terminal` in its options and `createRscMcpServer` mounts the MCP value. In `agent-bundle/test`, the `tty` knob of `invokeCli` and `runScript` shapes a deterministic synthetic terminal, `renderRoute` and the in-memory MCP level mount what the artifact would, and `context.terminal` injects any other value. Fixes #511 (#534) diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 3d9ab927a..abe9d69e8 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -563,6 +563,16 @@ Wiring rules: `install/surface.ts`), and child/worker stderr forwarding keep their direct `process.stdout`/`process.stderr` adapters: emitted artifacts must not carry a platform runtime, and byte-exact protocol frames are not terminal text. +- **The route-facing terminal capability is plain Node, not `Terminal`.** + `request.terminal` (#511) — TTY-ness, color depth, and `columns`/`rows` per + output stream, reported to routes, rendered scripts, and `main`-envelope + executables — is probed by the dependency-free `src/terminal-capability.ts` + (aliased into emitted executables as `agent-bundle/terminal-capability`) + because those artifacts must not carry the Effect runtime; the first-party + CLI mounts no route request scope, so it has nothing to read from the + `Terminal` service for it. `Terminal.columns`/`rows` remain the first-party + CLI's own way to size its human output. Rules and the per-surface table: + [Terminal capability](entry-conventions.md#terminal-capability-requestterminal). ## Effect Schema wire contracts (Schema projections) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 63de2ce82..c4c741f99 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -594,6 +594,72 @@ the in-memory MCP proof level accepts a registry (`openInMemoryMcpServer({ lineage, lineageHost })`) so hook→MCP correlation is testable without a spawned process. +#### Terminal capability (`request.terminal`) + +`(await agent()).terminal` is an `Observed` (#511): what the +process's output streams are, computed **once per invocation by the framework +shell** with the same rules that pick the CLI output mode, so a route that +colors its own stderr or sizes its own table agrees with the framework's +rendering instead of re-probing `process.stdout` per plugin. It is information +only — never a writer — and it never changes what `Agent.*` components render. + +```ts +interface AgentTerminal { + hostSurface: 'cli' | 'mcp' | 'hook' | 'script' | 'workbench'; + stdout: AgentTerminalStream; + stderr: AgentTerminalStream; + sharesTarget: boolean; // fd 1 and fd 2 name one open file (`2>&1`, one shared terminal) +} +interface AgentTerminalStream { + kind: 'tty' | 'pipe' | 'none'; // interactive terminal | any other open descriptor | no stream for the route + color: 'none' | 'basic' | '256' | 'truecolor'; + columns?: number; // present for a terminal, or when COLUMNS overrides + rows?: number; // present for a terminal, or when LINES overrides +} +``` + +The probe (`src/terminal-capability.ts`, plain Node, dependency-free, aliased +into emitted executables as `agent-bundle/terminal-capability`) reads +`isTTY`, `columns`, and `rows` off `process.stdout`/`process.stderr`, `fstat`s +the descriptors (`tty`; any other open descriptor is `pipe`; a closed one is +`none`; `sharesTarget` compares device and inode), and resolves color in the +informal standards' precedence: `FORCE_COLOR` decides outright when set +(`0`/`false` off; empty, `1`, or `true` basic; `2` 256; `3` truecolor — Node's +reading), then `CLICOLOR_FORCE` forces color on even for a pipe at the depth +`COLORTERM`/`TERM` advertise, `NO_COLOR` (any non-empty value) and `CLICOLOR=0` +force it off, `TERM=dumb` renders none, and otherwise a terminal renders at its +advertised depth while a pipe renders none. `COLUMNS`/`LINES` override the +reported size whatever the stream is. The routed CLI derives its `tty` versus +piped-Markdown mode from this same value (`stdout.kind === 'tty'`), so the two +can never disagree. + +Per surface, the value the generated request scope mounts: + +| Surface | `hostSurface` | `stdout` / `stderr` | Source | +| --- | --- | --- | --- | +| Routed CLI executable (`dist/bin/.js`, `/bin/.mjs`), plain or rendered command, projected MCP command | `cli` | Probed from the executable's own process; a rendered command's worker thread receives the executable's probe, never its own pipes. Machine output owns fd 1, so `stdout` describes where the rendered document lands and `stderr` the channel a route may write to itself. | `native` | +| Rendered script (`scripts/.mjs` from `src/scripts/.tsx`) | `script` | Probed, as above. | `native` | +| Generated MCP server (any transport) | `mcp` | `none` on both, `color: 'none'`, `sharesTarget: false` — stdout is the protocol wire and stderr the host's log. Never probed, whatever the descriptors are. | `derived` | +| Event route (shared runtime or standalone hook process) | `hook` | `none` on both — stdout is the host's hook envelope. Never probed. | `derived` | +| Workbench lifecycle replay | `workbench` | `none` on both — the document renders into a panel. | `derived` | +| `createRscMcpServer` (the `defineRscApplication` MCP adapter) | `mcp` | `none` on both, as above. | `derived` | +| `runRscCli` (the `defineRscApplication` CLI adapter) | `cli` when the caller passes `terminal` in its options | The adapter owns no probe — the generated routed-CLI shell does — so the caller's value is mounted `native`; omitted, the axis is `unavailable` (`not-provided`). | `native` / — | +| Custom host calling `runAgentRequest` without `terminal` | — | `unavailable` (`not-provided`) | — | + +Plain `main`-exporting scripts and bins have no request scope, so the +executable envelope hands them the same probe directly as the second argument +of `main` (see [The executable envelope](#the-executable-envelope-bin--scripts)). + +The `agent-bundle/test` harness never probes the test runner's own streams: +`invokeCli` and `runScript` mount a deterministic synthetic value shaped by +their `tty` knob (an 80×24 `basic`-color terminal on both streams with +`sharesTarget: true`, or two `color: 'none'` pipes), `renderRoute` mounts what +the artifact's scope for that route kind would (`none` for MCP and event +routes, the piped shape for `cli` and `script` kinds), the in-memory MCP level +forwards the real server's `none`, and a plain script's `main` receives the +real child process's probe (two pipes). A test that wants other values injects +`context.terminal` through the same seam as every identity axis. + ### Migration nudges Source validation reports **informational** nudges (never errors — migrations @@ -625,17 +691,27 @@ default function for bin entries) receives the generated process envelope: ```ts // src/cli.ts — the whole CLI entry a consumer writes -export const main = async (argv: readonly string[]): Promise => { - // ... +import type { ExecutableMainContext } from 'agent-bundle'; + +export const main = async (argv: readonly string[], { terminal }: ExecutableMainContext): Promise => { + if (terminal.stderr.color !== 'none') { /* paint progress on stderr */ } return 0; }; ``` -The envelope awaits `main(process.argv.slice(2))`, adopts a numeric return as -the process exit code, and lets an escaped rejection surface through Node's -top-level failure path (stack to stderr, exit code 1). Self-executing modules -(no `main` export) bundle directly, byte for byte — existing Scripts keep -their behavior. +The envelope awaits `main(process.argv.slice(2), { terminal })`, adopts a +numeric return as the process exit code, and lets an escaped rejection surface +through Node's top-level failure path (stack to stderr, exit code 1). +`terminal` is the process's [terminal capability](#terminal-capability-requestterminal) +(#511), probed once before `main` runs by the dependency-free +`agent-bundle/terminal-capability` module the envelope aliases in — plain +scripts and bins load no Effect runtime and no `@agent-bundle/runtime` for it. +Its `hostSurface` is `cli` for a package bin (`dist/bin/.js`) and +`script` for an artifact script (`scripts/.mjs`); a module shipped on +both surfaces sees the surface it was launched from. A `main` declared with +one parameter keeps working — the second argument is simply unread. +Self-executing modules (no `main` export) bundle directly, byte for byte — +existing Scripts keep their behavior and receive no probe. ### The routed CLI shell (#102 stages 2-3) diff --git a/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx b/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx index 12467cae5..22009d838 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx @@ -11,10 +11,15 @@ export default async function AfterTool({ canonical }: AgentEventRouteProps) { const actorContext = context.actor.state === 'available' ? `actor available:${context.actor.source}:${context.actor.value.id}` : `actor unavailable:${context.actor.reason}`; + // A hook has no terminal (#511); the route reports what it observed. + const terminalContext = context.terminal.state === 'available' + ? `terminal available:${context.terminal.source} ${context.terminal.value.hostSurface}/${context.terminal.value.stdout.kind}/${context.terminal.value.stderr.kind}` + : `terminal unavailable:${context.terminal.reason}`; return ( {`Observed ${canonical.event} from ${canonical.provenance.host}.`} {actorContext} + {terminalContext} {notices.map((notice) => ( {`notice ${notice.id}: ${notice.message}`} ))} diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/context.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/context.tsx index 4b6d15138..3150d20d0 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/context.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/context.tsx @@ -16,6 +16,7 @@ export const resultSchema = z.object({ host: z.unknown(), lineage: z.unknown(), session: z.unknown(), + terminal: z.unknown(), workspace: z.unknown(), }).strict(); @@ -36,11 +37,15 @@ export default async function Context() { const lineage: JsonValue = context.lineage.state === 'available' ? { source: context.lineage.source, state: context.lineage.state, value: JSON.parse(JSON.stringify(context.lineage.value)) as JsonValue } : { reason: context.lineage.reason, state: context.lineage.state }; + const terminal: JsonValue = context.terminal.state === 'available' + ? { source: context.terminal.source, state: context.terminal.state, value: JSON.parse(JSON.stringify(context.terminal.value)) as JsonValue } + : { reason: context.terminal.reason, state: context.terminal.state }; const result = { actor, host, lineage, session, + terminal, workspace, }; return ( diff --git a/packages/agent-bundle/fixtures/route-harness/src/scripts/checksum.ts b/packages/agent-bundle/fixtures/route-harness/src/scripts/checksum.ts index ee13e3efb..48e0b0786 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/scripts/checksum.ts +++ b/packages/agent-bundle/fixtures/route-harness/src/scripts/checksum.ts @@ -1,17 +1,25 @@ +import type { ExecutableMainContext } from 'agent-bundle'; + /** * A plain script with the `main` process-envelope contract: the generated - * `scripts/checksum.mjs` awaits `main(process.argv.slice(2))` and adopts a - * numeric return as the exit code. No renderer, no request context. + * `scripts/checksum.mjs` awaits `main(process.argv.slice(2), { terminal })` + * and adopts a numeric return as the exit code. No renderer, no request + * context; the terminal capability (#511) arrives through the envelope. */ /** Module state: a fresh process starts at zero, a cached module would not. */ let calls = 0; -export const main = async (argv: readonly string[]): Promise => { +export const main = async (argv: readonly string[], context: ExecutableMainContext): Promise => { calls += 1; if (argv.includes('--explode')) { throw new Error('checksum exploded'); } + if (argv.includes('--terminal')) { + // What the envelope probed for this process, as one canonical JSON line. + process.stdout.write(`${JSON.stringify(context.terminal)}\n`); + return 0; + } if (argv.includes('--calls')) { process.stdout.write(`checksum call ${String(calls)} in ${process.argv[1]!}\n`); return 0; diff --git a/packages/agent-bundle/fixtures/route-harness/src/scripts/summary.tsx b/packages/agent-bundle/fixtures/route-harness/src/scripts/summary.tsx index f0ebf569e..82251d50c 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/scripts/summary.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/scripts/summary.tsx @@ -57,6 +57,10 @@ export default async function Summary({ argv, signal }: ScriptRouteProps) { invocation: context.invocation.kind, stateMounted: context.state !== undefined, surface: context.invocation.surface ?? null, + // The executable's probed terminal (#511), as `//`. + terminal: context.terminal.state === 'available' + ? `${context.terminal.value.hostSurface}/${context.terminal.value.stdout.kind}/${context.terminal.value.stderr.kind}` + : `unavailable:${context.terminal.reason}`, }; if (argv.includes('--fail')) { return ( diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index a7cdfafa4..d1cdedcf4 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -112,6 +112,7 @@ export default defineConfig({ // into its generated bundle. routes: './src/routes/public.ts', rstest: './src/rstest/index.ts', + 'terminal-capability': './src/terminal-capability.ts', test: './src/test/index.ts', 'test/browser': './src/test/browser.ts', }, diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 2676b871a..672caa680 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -716,6 +716,7 @@ const eventRouteHookWrapperSource = ( ' lineage: context.lineage,', ' requestInvocation: context.invocation,', ' session: context.session,', + ' terminal: context.terminal,', ' type: "render",', ' workspace: context.workspace,', ' });', @@ -763,6 +764,8 @@ const eventRouteHookWrapperSource = ( ' lineage,', ' ...(sessionId === undefined ? {} : { session: available({ sessionId }, "native") }),', ' signal,', + // A hook's stdout is its host envelope: no terminal, never probed (#511). + ' terminal: available({ hostSurface: "hook", sharesTarget: false, stderr: { color: "none", kind: "none" }, stdout: { color: "none", kind: "none" } }, "derived"),', ' ...(workspaceRoot === undefined ? {} : { workspace: available({ root: workspaceRoot }, "native") }),', ' }, async () => renderStandalone({ kind: "event", props: { event: canonicalEvent, payload: { canonical: props.canonical, native: props.native } } }, signal));', ' return projectEventDocument(document, canonicalEvent, target, nativeEvent, native);', diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 028d8f23a..676116d20 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -29,6 +29,8 @@ import { generatedExecutableEntrySource, generatedRenderedRouteWorkerSource, generatedRenderedScriptEntrySource, + terminalCapabilityRuntimePath, + terminalCapabilityRuntimeSpecifier, generatedRouteArtifactEpoch, generatedRouteFlightWorkerSource, generatedRouteMcpEntrySource, @@ -41,7 +43,7 @@ import { import { emptyRouteConfig, type CompiledLayout, type CompiledProvider } from '../routes/types.ts'; import type { CompiledMcpApp } from './mcp-apps.ts'; import type { ArtifactOutputKind } from './provenance.ts'; -import type { RslibSurfacePlan } from './rslib.ts'; +import type { RslibEntry, RslibSurfacePlan } from './rslib.ts'; const eventRuntimeModulePath = (module: 'ipc' | 'project'): string => { for (const candidate of [ @@ -176,8 +178,16 @@ export const planScriptsSurface = async ( const cliRuntimeShell = bundled.some((entry) => entry.rendered !== undefined) ? cliEntryRuntimePath() : undefined; + // The builder decides the envelope statically, before any module runs. + const mainExports = new Map(await Promise.all(bundled + .filter((entry) => entry.rendered === undefined) + .map(async (entry) => [entry.source, (await scanEntryExports(entry.source)).hasMainExport] as const))); + const terminalProbe = [...mainExports.values()].some(Boolean) ? terminalCapabilityRuntimePath() : undefined; + // Every compiler-owned runtime module lives in this package, so one ignored + // root covers the cli-entry shell and the terminal probe alike. + const ignoredRuntime = cliRuntimeShell ?? terminalProbe; return { - entries: await Promise.all(bundled.flatMap((entry) => { + entries: await Promise.all(bundled.flatMap((entry): readonly Promise[] => { const { name, rendered, source, sourceInputs } = entry; if (rendered !== undefined) { const workerSourceInputs = Object.freeze([...new Set([ @@ -228,22 +238,23 @@ export const planScriptsSurface = async ( ]; } // A Script whose module exports `main` receives the framework process - // envelope (argv, numeric exit codes); self-executing modules keep - // today's direct-bundle behavior byte for byte. - return [(async () => { - const exports = await scanEntryExports(source); - return { - name, - outputRelativePath: `scripts/${name}.mjs`, - source, - sourceInputs, - ...(exports.hasMainExport - ? { virtualSource: generatedExecutableEntrySource({ entrySource: source, exportName: 'main' }) } - : {}), - }; - })()]; + // envelope (argv, the terminal capability, numeric exit codes); + // self-executing modules keep today's direct-bundle behavior byte for + // byte. + return [Promise.resolve({ + name, + outputRelativePath: `scripts/${name}.mjs`, + source, + sourceInputs, + ...(mainExports.get(source) === true + ? { + aliases: { [terminalCapabilityRuntimeSpecifier]: terminalProbe! }, + virtualSource: generatedExecutableEntrySource({ entrySource: source, exportName: 'main', hostSurface: 'script' }), + } + : {}), + })]; })), - ...(cliRuntimeShell === undefined ? {} : { ignoredSourcePaths: [runtimeIgnoredRoot(cliRuntimeShell)] }), + ...(ignoredRuntime === undefined ? {} : { ignoredSourcePaths: [runtimeIgnoredRoot(ignoredRuntime)] }), finish: async (evidence) => { await emitPlanEntries({ entries: await Promise.all(compiled diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 99e42a52b..976ca83aa 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -52,6 +52,18 @@ export const mcpEntryRuntimePath = (): string => runtimeModulePath('mcp-entry'); export const mcpServerRuntimePath = (): string => runtimeModulePath('mcp-server-runtime'); +/** + * The terminal-capability probe (#511) aliased into `main`-envelope + * executables: plain Node, dependency-free, so a plain script or bin learns + * its TTY-ness, color, and size without loading the routed-CLI shell. + */ +export const terminalCapabilityRuntimeSpecifier = 'agent-bundle/terminal-capability'; + +export const terminalCapabilityRuntimePath = (): string => runtimeModulePath('terminal-capability'); + +/** The surface a `main`-envelope executable reports as its `terminal.hostSurface`. */ +export type GeneratedExecutableSurface = 'cli' | 'script'; + /** * The generated stdio MCP entry body for a factory-exporting server module: * the lifecycle installs the console guard before the consumer module @@ -73,21 +85,24 @@ export const generatedStdioMcpEntrySource = (options: { /** * The generated process envelope for a `main`- or default-exporting * executable entry (npm bin outputs and artifact Scripts): await the entry - * point with argv, adopt a numeric return as the exit code, and let an - * escaped rejection surface through Node's top-level failure path (stack to - * stderr, exit code 1). + * point with argv and the process's terminal capability (#511), adopt a + * numeric return as the exit code, and let an escaped rejection surface + * through Node's top-level failure path (stack to stderr, exit code 1). */ export const generatedExecutableEntrySource = (options: { readonly entrySource: string; readonly exportName: 'default' | 'main'; + /** `cli` for a package bin, `script` for an artifact script; defaults to `script`. */ + readonly hostSurface?: GeneratedExecutableSurface; }): string => [ + `import { detectProcessTerminal } from ${JSON.stringify(terminalCapabilityRuntimeSpecifier)};`, `import * as entry from ${JSON.stringify(options.entrySource)};`, '', `const main = entry[${JSON.stringify(options.exportName)}];`, "if (typeof main !== 'function') {", ` throw new TypeError('Executable entry must export a ${options.exportName} function: ' + ${JSON.stringify(options.entrySource)});`, '}', - 'const code = await main(process.argv.slice(2));', + `const code = await main(process.argv.slice(2), Object.freeze({ terminal: detectProcessTerminal(${JSON.stringify(options.hostSurface ?? 'script')}) }));`, "if (typeof code === 'number') process.exitCode = code;", '', ].join('\n'); @@ -230,7 +245,7 @@ const generatedStateOwner = ( * worker stdout guarded onto stderr (machine output owns stdout). */ const renderedSessionSource = (workerFile: string): readonly string[] => [ - 'const openRenderedSession = ({ invocation, props, request, routeId, signal, validate }) => {', + 'const openRenderedSession = ({ invocation, props, request, routeId, signal, terminal, validate }) => {', ` const worker = new Worker(new URL(${JSON.stringify(`./${workerFile}`)}, import.meta.url), { stderr: true, stdout: true });`, " worker.stdout?.on('data', (chunk) => process.stderr.write(chunk));", " worker.stderr?.on('data', (chunk) => process.stderr.write(chunk));", @@ -266,8 +281,10 @@ const renderedSessionSource = (workerFile: string): readonly string[] => [ " dispatch.signal.addEventListener('abort', entry.abort, { once: true });", ' if (dispatch.signal.aborted) { entry.abort(); return stream; }', // 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' });", + // real surface (`cli`, `script`, `tool`) instead of an undefined invocation; + // the terminal capability rides with it because a worker thread's own + // streams are pipes to this process, never the terminal (#511). + " worker.postMessage({ id, invocation, props, request, routeId, terminal, type: 'render' });", ' return stream;', ' },', ' });', @@ -361,6 +378,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ` providers: ${providerValuesExpression(providers)},`, ' signal: context.signal,', ...(options.state === undefined ? [] : [' state: bindings.state,']), + " terminal: available(context.terminal, 'native'),", " workspace: available({ root: cwd }, 'derived'),", ` }, async () => route.module.default({ input: parsed, signal: context.signal }));`, `${options.state === undefined ? ' ' : ' '}return route.module.resultSchema.parse(result);`, @@ -383,6 +401,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ` request: { artifactEpoch: ${JSON.stringify(generatedRouteArtifactEpoch(options.plugin))}, kind: 'tool', operationId: command.routeId, surface: command.mcp.tool },`, ' routeId: command.routeId,', ' signal: context.signal,', + ' terminal: context.terminal,', ' validate: (value) => route.module.resultSchema.parse(value),', ' });', ' }', @@ -392,6 +411,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) " request: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') },", ' routeId: command.routeId,', ' signal: context.signal,', + ' terminal: context.terminal,', ' validate: (value) => route.module.resultSchema.parse(value),', ' });', '};', @@ -560,6 +580,9 @@ export const generatedRenderedRouteWorkerSource = ( ` providers: ${providerValuesExpression(providers)},`, ' signal: controller.signal,', ...(options.state === undefined ? [] : [' state: bindings.state,']), + // The executable probed its terminal once and forwards the value; a worker + // thread cannot probe it (its streams are pipes to the parent). + " terminal: message.terminal === undefined ? unavailable('not-provided') : available(message.terminal, 'native'),", " workspace: available({ root: cwd }, 'derived'),", ' }, async () => {', ' const flight = renderAgentFlight(composeLayouts(route, { ...message.props, signal: controller.signal }, controller.signal), { signal: controller.signal });', @@ -622,6 +645,7 @@ export const generatedRenderedScriptEntrySource = ( ` request: { kind: 'script', operationId: ${JSON.stringify(options.routeId)}, surface: ${JSON.stringify(options.name)} },`, ` routeId: ${JSON.stringify(options.routeId)},`, ' signal: context.signal,', + ' terminal: context.terminal,', ' validate: (value) => value,', ' }),', ` name: ${JSON.stringify(options.name)},`, @@ -904,6 +928,9 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo ' ...(message.session === undefined ? {} : { session: message.session }),', ' signal: controller.signal,', ...(options.state === undefined ? [] : [' state: bindings.state,']), + // MCP and hook surfaces have no terminal; the host scope says so and the + // worker forwards it rather than probing its own pipes (#511). + " terminal: message.terminal ?? unavailable('not-provided'),", ' ...(message.workspace === undefined ? {} : { workspace: message.workspace }),', ' }, async () => {', " const props = message.invocation.kind === 'event'", diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 876c7c5f8..0a9c967fd 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -13,6 +13,8 @@ import { mcpEntryRuntimeSpecifier, mcpServerRuntimePath, mcpServerRuntimeSpecifier, + terminalCapabilityRuntimePath, + terminalCapabilityRuntimeSpecifier, } from './entry-shell.ts'; import { cliBinRslibEntries, planCompiledCliBins } from './cli-bins.ts'; import { planCompiledMcpEntries } from './entries.ts'; @@ -145,9 +147,11 @@ const scriptEntries = async ( sourceInputs: [], ...(exports.hasMainExport ? { + aliases: { [terminalCapabilityRuntimeSpecifier]: terminalCapabilityRuntimePath() }, virtualSource: generatedExecutableEntrySource({ entrySource: script.source, exportName: 'main', + hostSurface: 'script', }), } : {}), diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 61b2e19e4..4a4711472 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -18,6 +18,8 @@ import { generatedRenderedRouteWorkerSource, installEntryRuntimePath, installEntryRuntimeSpecifier, + terminalCapabilityRuntimePath, + terminalCapabilityRuntimeSpecifier, } from './entry-shell.ts'; import { projectMeta } from './meta.ts'; import type { BundledOutputEvidence } from './provenance.ts'; @@ -183,7 +185,12 @@ export const planPackageEntries = async ( sourceInputs: Object.freeze([bin.provenance.sourcePath, bin.source]), ...(exportName === undefined ? {} - : { virtualSource: generatedExecutableEntrySource({ entrySource: bin.source, exportName }) }), + : { + // The envelope probes the terminal (#511) through the aliased + // dependency-free runtime module, like the cli-entry shell. + aliases: { [terminalCapabilityRuntimeSpecifier]: terminalCapabilityRuntimePath() }, + virtualSource: generatedExecutableEntrySource({ entrySource: bin.source, exportName, hostSurface: 'cli' }), + }), }); } const installHosts = Object.freeze((['claude', 'codex', 'cursor'] as const) @@ -302,14 +309,17 @@ export const buildPackageOutputs = async (options: { await mkdir(stageParent, { recursive: true }); const stageRoot = await mkdtemp(join(stageParent, `.${basename(outputRoot)}.stage-`)); try { - const ignoredRuntimeRoots = Object.freeze([ + const ignoredRuntimeRoots = Object.freeze([...new Set([ ...(entries.some((entry) => entry.aliases?.[cliEntryRuntimeSpecifier] !== undefined) ? [runtimeIgnoredRoot(cliEntryRuntimePath())] : []), ...(entries.some((entry) => entry.aliases?.[installEntryRuntimeSpecifier] !== undefined) ? [runtimeIgnoredRoot(installEntryRuntimePath())] : []), - ]); + ...(entries.some((entry) => entry.aliases?.[terminalCapabilityRuntimeSpecifier] !== undefined) + ? [runtimeIgnoredRoot(terminalCapabilityRuntimePath())] + : []), + ])]); const evidence = await buildPackageEntries({ cwd: projectRoot, entries, diff --git a/packages/agent-bundle/src/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts index f443661cc..060e663af 100644 --- a/packages/agent-bundle/src/cli-entry.ts +++ b/packages/agent-bundle/src/cli-entry.ts @@ -1,5 +1,13 @@ import type { CompiledCliCommand, CompiledCliOption } from './routes/types.ts'; import { stableJson } from './core/digest.ts'; +import { + detectProcessTerminal, + type AgentTerminal, + type ProbedTerminalSurface, + type TerminalStreamProbe, +} from './terminal-capability.ts'; + +export type { AgentTerminal } from './terminal-capability.ts'; /** * The framework-owned routed-CLI shell (#102 stage 2): command-tree @@ -286,14 +294,40 @@ export interface GeneratedCliExecuteContext { /** True when `--json` was passed; plain commands already emit canonical JSON. */ readonly json: boolean; readonly signal: AbortSignal; + /** The process's terminal capability (#511), probed once by the shell; the executable mounts it as `request.terminal`. */ + readonly terminal: AgentTerminal; } export interface GeneratedCliRenderContext { /** The raw argv the command consumed, for the dispatch invocation's `args`. */ readonly args: readonly string[]; readonly signal: AbortSignal; + /** The process's terminal capability (#511), the same value that selected the output mode. */ + readonly terminal: AgentTerminal; } +/** + * The terminal capability one shell invocation reports (#511): an explicit + * value wins (the in-process harness supplies one), otherwise the process's + * own streams are probed, with the legacy `isTty` knob standing in for + * stdout's TTY-ness so callers that only override that still see a + * consistent capability and output mode. + */ +const resolveTerminal = ( + hostSurface: ProbedTerminalSurface, + options: { readonly isTty?: () => boolean; readonly terminal?: AgentTerminal }, +): AgentTerminal => { + if (options.terminal !== undefined) return options.terminal; + if (options.isTty === undefined) return detectProcessTerminal(hostSurface); + const stdout: TerminalStreamProbe = { + columns: process.stdout.columns, + fd: 1, + isTTY: options.isTty(), + rows: process.stdout.rows, + }; + return detectProcessTerminal(hostSurface, { stdout }); +}; + export interface RunGeneratedCliOptions { readonly argv: readonly string[]; readonly commands: readonly CompiledCliCommand[]; @@ -304,7 +338,7 @@ export interface RunGeneratedCliOptions { input: Readonly>, context: GeneratedCliExecuteContext, ) => Promise; - /** True when stdout is an interactive terminal; rendered commands then update progress in place. */ + /** Overrides stdout's TTY-ness only; rendered commands then update progress in place. Prefer `terminal`. */ readonly isTty?: () => boolean; readonly name: string; /** Opens one rendered run for a resolved `.tsx` command with parsed input. */ @@ -314,6 +348,11 @@ export interface RunGeneratedCliOptions { context: GeneratedCliRenderContext, ) => GeneratedCliRenderSession; readonly signal?: AbortSignal; + /** + * The terminal capability to report and select the output mode from (#511). + * Omitted, the shell probes this process's stdout and stderr once. + */ + readonly terminal?: AgentTerminal; readonly version: string; readonly writeErr?: (text: string) => void; readonly writeOut?: (text: string) => void; @@ -886,6 +925,9 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro } parsed = parseMcpCommandInput(command, parseCommandArgv(command, rest)); signal.throwIfAborted(); + // Probed once: the same value selects the output mode and reaches the + // route as `request.terminal`, so the two can never disagree. + const terminal = resolveTerminal('cli', options); if (command.rendered) { if (options.render === undefined) { throw new Error(`Rendered command ${command.path.join(' ')} has no render host.`); @@ -894,10 +936,10 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro ? 'ndjson' : parsed.json ? 'json' - : (options.isTty ?? (() => process.stdout.isTTY === true))() + : terminal.stdout.kind === 'tty' ? 'tty' : 'markdown'; - const session = options.render(command, parsed.input, { args: rest, signal }); + const session = options.render(command, parsed.input, { args: rest, signal, terminal }); try { return await runRenderedInvocation({ exitCode: command.exitCode, @@ -912,7 +954,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, { args: rest, json: parsed.json, signal }); + const result = await options.execute(command, parsed.input, { args: rest, json: parsed.json, signal, terminal }); signal.throwIfAborted(); const exitCode = resultExitCode(command.exitCode, result); writeOut(`${stableJson(result === undefined ? null : result)}\n`); @@ -992,11 +1034,14 @@ export interface RunGeneratedRenderedScriptOptions { /** Opens one rendered run for the script with the mode flags removed from argv. */ readonly createSession: ( argv: readonly string[], - context: { readonly signal: AbortSignal }, + context: { readonly signal: AbortSignal; readonly terminal: AgentTerminal }, ) => GeneratedCliRenderSession; + /** Overrides stdout's TTY-ness only. Prefer `terminal`. */ readonly isTty?: () => boolean; readonly name: string; readonly signal?: AbortSignal; + /** The terminal capability to report and select the output mode from (#511); probed from the process when omitted. */ + readonly terminal?: AgentTerminal; readonly writeErr?: (text: string) => void; readonly writeOut?: (text: string) => void; } @@ -1024,14 +1069,15 @@ export const runGeneratedRenderedScript = async ( } const argv = options.argv.filter((argument, index) => (terminator !== -1 && index > terminator) || (argument !== '--json' && argument !== '--ndjson')); + const terminal = resolveTerminal('script', options); const mode: CliOutputMode = ndjson ? 'ndjson' : json ? 'json' - : (options.isTty ?? (() => process.stdout.isTTY === true))() + : terminal.stdout.kind === 'tty' ? 'tty' : 'markdown'; - const session = options.createSession(argv, { signal }); + const session = options.createSession(argv, { signal, terminal }); try { return await runRenderedInvocation({ exitCode: 'zero', diff --git a/packages/agent-bundle/src/dev/playground/lifecycle-render-child.ts b/packages/agent-bundle/src/dev/playground/lifecycle-render-child.ts index e570c4758..73fba6573 100644 --- a/packages/agent-bundle/src/dev/playground/lifecycle-render-child.ts +++ b/packages/agent-bundle/src/dev/playground/lifecycle-render-child.ts @@ -3,6 +3,7 @@ import { createJiti } from 'jiti'; import * as React from 'react'; import { createCanonicalEventProps } from '../../events/project.ts'; +import { noTerminal } from '../../terminal-capability.ts'; import { renderRouteEvents } from '../../test/render.ts'; import type { AgentRouteModule } from '../../test/types.ts'; import type { @@ -69,6 +70,8 @@ const render = async (request: LifecycleRenderChildRequest): Promise; readonly lineage: Observed; readonly session?: Observed; + readonly terminal: Observed; readonly workspace: Observed; } @@ -205,6 +208,9 @@ const requestIdentity = ( ...(typeof context.sessionId === 'string' && context.sessionId.trim() !== '' ? { session: available({ sessionId: context.sessionId }, 'native') } : {}), + // An MCP server's stdout is the protocol wire and its stderr the host's + // log: no terminal, whatever the descriptors happen to be (#511). + terminal: available(noTerminal('mcp'), 'derived'), workspace: available({ root: process.cwd() }, 'derived'), }); @@ -571,6 +577,7 @@ export const createFlightWorkerHost = ( lineage: context.lineage, requestInvocation: context.invocation, session: context.session, + terminal: context.terminal, type: 'render', workspace: context.workspace, }); @@ -884,6 +891,8 @@ const startEventRuntime = async ( lineage, ...(sessionId === undefined ? {} : { session: available({ sessionId }, 'native') }), signal, + // A hook's stdout is its host envelope: no terminal (#511). + terminal: available(noTerminal('hook'), 'derived'), ...(workspaceRoot === undefined ? {} : { workspace: available({ root: workspaceRoot }, 'native') }), }, async () => events.projectEventDocument( // The host scope remains ledger-free: the Flight worker owns the one diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 5dfa6d409..9b8364a9f 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -1,4 +1,5 @@ import type { JsonValue } from '../core/strict-json.ts'; +import type { AgentTerminal } from '../terminal-capability.ts'; /** The structural schema surface route props infer without coupling to one schema library. */ export interface RouteSchema { @@ -276,3 +277,22 @@ export interface ScriptRouteProps { readonly argv: readonly string[]; readonly signal: AbortSignal; } + +export type { + AgentTerminal, + AgentTerminalColor, + AgentTerminalStream, + AgentTerminalStreamKind, + AgentTerminalSurface, +} from '../terminal-capability.ts'; + +/** + * The second argument the generated executable envelope passes to a plain + * script's or bin's `main(argv, context)` (#511): the process's terminal + * capability, probed once before `main` runs. A rendered route reads the same + * shape from `(await agent()).terminal`; a plain script has no request scope, + * so the envelope hands it the value directly. + */ +export interface ExecutableMainContext { + readonly terminal: AgentTerminal; +} diff --git a/packages/agent-bundle/src/terminal-capability.ts b/packages/agent-bundle/src/terminal-capability.ts new file mode 100644 index 000000000..a44ad07a8 --- /dev/null +++ b/packages/agent-bundle/src/terminal-capability.ts @@ -0,0 +1,228 @@ +import { fstatSync } from 'node:fs'; + +/** + * The terminal capability generated executables report to routes and + * scripts (#511): TTY-ness, color support, and size per output stream, + * detected once per process by the framework shell with the same rules that + * pick the CLI output mode. Plain Node with no dependencies — it is aliased + * into every generated bin, rendered script, and `main`-envelope script, and + * it must not load the Effect runtime or `@agent-bundle/runtime`. The types + * are structural mirrors of the runtime's `AgentTerminal` family so a + * consumer's `main(argv, { terminal })` and `(await agent()).terminal.value` + * read one shape. + */ + +/** + * `tty` is an interactive terminal; `pipe` any other open descriptor (a pipe, + * a file, a socket, `/dev/null`); `none` means no human-facing stream exists + * for the route on this surface — an MCP server's stdout is the protocol wire + * and a hook's is its host envelope. + */ +export type AgentTerminalStreamKind = 'tty' | 'pipe' | 'none'; + +/** `basic` is 16 colors, `256` the xterm palette, `truecolor` 24-bit. */ +export type AgentTerminalColor = 'none' | 'basic' | '256' | 'truecolor'; + +/** + * The projection a request runs under. `cli`, `script`, and `workbench` own + * a probed process; `mcp` and `hook` never have a terminal and always report + * `none`. + */ +export type AgentTerminalSurface = 'cli' | 'mcp' | 'hook' | 'script' | 'workbench'; + +export interface AgentTerminalStream { + /** Present when the stream is a terminal or `COLUMNS` overrides it. */ + readonly columns?: number; + readonly color: AgentTerminalColor; + readonly kind: AgentTerminalStreamKind; + /** Present when the stream is a terminal or `LINES` overrides it. */ + readonly rows?: number; +} + +/** + * What a route or script may assume about the process's output streams. + * Information only, never a writer: under the routed CLI and rendered + * scripts machine output owns stdout, so `stdout` describes where the + * rendered document lands and `stderr` the channel a route may write to + * itself; a plain `main` script owns both. + */ +export interface AgentTerminal { + readonly hostSurface: AgentTerminalSurface; + /** Whether stdout and stderr name the same open file (`2>&1`, one shared terminal). */ + readonly sharesTarget: boolean; + readonly stderr: AgentTerminalStream; + readonly stdout: AgentTerminalStream; +} + +/** The surfaces whose process streams the shell probes. */ +export type ProbedTerminalSurface = Extract; + +/** The surfaces that never have a terminal, whatever their descriptors are. */ +export type TerminalFreeSurface = Exclude; + +/** One output stream as the shell sees it: the descriptor plus what Node's `tty.WriteStream` reports. */ +export interface TerminalStreamProbe { + readonly columns?: number | undefined; + readonly fd: number; + readonly isTTY?: boolean | undefined; + readonly rows?: number | undefined; +} + +export interface DetectTerminalOptions { + /** Defaults to `process.env`. */ + readonly env?: Readonly>; + /** Defaults to `process.stderr` on descriptor 2. */ + readonly stderr?: TerminalStreamProbe; + /** Defaults to `process.stdout` on descriptor 1. */ + readonly stdout?: TerminalStreamProbe; +} + +const isSet = (value: string | undefined): value is string => value !== undefined && value !== ''; + +const isOn = (value: string): boolean => value !== '0' && value.toLowerCase() !== 'false'; + +/** The depth an attached terminal renders, from `COLORTERM` and `TERM`; `basic` when neither says more. */ +const terminalDepth = (env: Readonly>): AgentTerminalColor => { + const colorterm = env.COLORTERM?.toLowerCase(); + if (colorterm === 'truecolor' || colorterm === '24bit') return 'truecolor'; + if (/-256(?:color)?$/u.test(env.TERM ?? '')) return '256'; + return 'basic'; +}; + +/** + * Color for one stream, following the informal standards in their usual + * precedence: `FORCE_COLOR` decides outright when set (`0`/`false` off, + * `1`/`true`/empty basic, `2` 256, `3` truecolor — Node's own reading); + * `CLICOLOR_FORCE` forces color on even for a pipe; `NO_COLOR` (any non-empty + * value) and `CLICOLOR=0` force it off; `TERM=dumb` cannot render it; + * otherwise color iff the stream is a terminal, at the depth `COLORTERM` and + * `TERM` advertise. + */ +export const terminalColor = ( + env: Readonly>, + isTty: boolean, +): AgentTerminalColor => { + const forceColor = env.FORCE_COLOR; + if (forceColor !== undefined) { + switch (forceColor) { + case '': + case '1': + case 'true': + return 'basic'; + case '2': + return '256'; + case '3': + return 'truecolor'; + default: + return 'none'; + } + } + const clicolorForce = env.CLICOLOR_FORCE; + if (isSet(clicolorForce) && isOn(clicolorForce)) return terminalDepth(env); + if (isSet(env.NO_COLOR)) return 'none'; + if (env.CLICOLOR === '0') return 'none'; + if (env.TERM === 'dumb') return 'none'; + return isTty ? terminalDepth(env) : 'none'; +}; + +/** A positive integer from an environment override such as `COLUMNS`; anything else is no override. */ +const dimensionOverride = (value: string | undefined): number | undefined => { + if (!isSet(value) || !/^\d+$/u.test(value)) return undefined; + const parsed = Number(value); + return parsed > 0 ? parsed : undefined; +}; + +const dimension = ( + override: string | undefined, + reported: number | undefined, + isTty: boolean, +): number | undefined => { + const overridden = dimensionOverride(override); + if (overridden !== undefined) return overridden; + return isTty && typeof reported === 'number' && reported > 0 ? reported : undefined; +}; + +/** `tty` when Node says so, `pipe` for any other open descriptor, `none` when the descriptor is closed. */ +const streamKind = (probe: TerminalStreamProbe): AgentTerminalStreamKind => { + if (probe.isTTY === true) return 'tty'; + try { + fstatSync(probe.fd); + return 'pipe'; + } catch { + return 'none'; + } +}; + +const probeStream = ( + probe: TerminalStreamProbe, + env: Readonly>, +): AgentTerminalStream => { + const kind = streamKind(probe); + const isTty = kind === 'tty'; + const columns = dimension(env.COLUMNS, probe.columns, isTty); + const rows = dimension(env.LINES, probe.rows, isTty); + return Object.freeze({ + color: kind === 'none' ? 'none' : terminalColor(env, isTty), + kind, + ...(columns === undefined ? {} : { columns }), + ...(rows === undefined ? {} : { rows }), + }); +}; + +/** + * Whether two descriptors name the same open file (device + inode): what + * `2>&1`, `| tee`, and one shared terminal look like from inside the process. + * A descriptor that cannot be inspected, or a platform that reports no inode, + * keeps the channels separate. + */ +export const sharesOutputTarget = (stdoutFd: number, stderrFd: number): boolean => { + try { + const out = fstatSync(stdoutFd); + const err = fstatSync(stderrFd); + return out.ino !== 0 && out.dev === err.dev && out.ino === err.ino; + } catch { + return false; + } +}; + +const processProbe = (stream: NodeJS.WriteStream, fd: number): TerminalStreamProbe => ({ + columns: stream.columns, + fd, + isTTY: stream.isTTY, + rows: stream.rows, +}); + +/** + * Probes this process's stdout and stderr once. The routed CLI shell calls it + * to pick its output mode and hands the same value to routes, so a route's + * decision to color its own stderr agrees with the framework's rendering. + */ +export const detectProcessTerminal = ( + hostSurface: ProbedTerminalSurface, + options: DetectTerminalOptions = {}, +): AgentTerminal => { + const env = options.env ?? process.env; + const stdout = options.stdout ?? processProbe(process.stdout, 1); + const stderr = options.stderr ?? processProbe(process.stderr, 2); + return Object.freeze({ + hostSurface, + sharesTarget: sharesOutputTarget(stdout.fd, stderr.fd), + stderr: probeStream(stderr, env), + stdout: probeStream(stdout, env), + }); +}; + +const closedStream: AgentTerminalStream = Object.freeze({ color: 'none', kind: 'none' }); + +/** + * The honest report for a surface that has no terminal: a generated MCP + * server's stdout is the protocol wire, a hook's is its host envelope, and a + * Workbench replay renders into a panel. Nothing is probed, so nothing can be + * guessed. + */ +export const noTerminal = (hostSurface: TerminalFreeSurface): AgentTerminal => Object.freeze({ + hostSurface, + sharesTarget: false, + stderr: closedStream, + stdout: closedStream, +}); diff --git a/packages/agent-bundle/src/test/cli.ts b/packages/agent-bundle/src/test/cli.ts index 46731be30..9fa5be46b 100644 --- a/packages/agent-bundle/src/test/cli.ts +++ b/packages/agent-bundle/src/test/cli.ts @@ -30,6 +30,7 @@ import { parseCanonicalJsonLine, parseRenderedEventLines } from './output-modes. import { claimProcessHit, mountProviders } from './providers.ts'; import { registeredRouteLoader, testManifest } from './registry.ts'; import { prepareCliRenderHost, type HarnessOptionsArguments, type RenderRouteContextInit } from './render.ts'; +import { harnessTerminal } from './terminal.ts'; import type { AgentRouteModule, RenderedRouteProvenance } from './types.ts'; export type { CliRenderedEvent }; @@ -38,8 +39,11 @@ export interface InvokeCliOptionsBase { readonly manifest?: AgentBundleTestManifest; readonly signal?: AbortSignal; /** - * Selects interactive rendered output explicitly. Generated binaries use - * `process.stdout.isTTY`; the in-process harness defaults to piped output. + * Selects interactive rendered output explicitly. Generated binaries probe + * `process.stdout`; the in-process harness defaults to piped output. The + * same knob shapes the `request.terminal` the command observes (#511): a + * synthetic 80×24 basic-color terminal on both streams, or two color-free + * pipes. Inject `context.terminal` to choose other values. */ readonly tty?: boolean; } @@ -262,6 +266,7 @@ export const invokeCli = async ( projectRoot: runtime.available({ root }, 'derived'), }, host: runtime.unavailable('unsupported-surface'), + terminal: runtime.available(execution.terminal, 'native'), workspace: runtime.available({ root }, 'derived'), ...context, providers, @@ -280,7 +285,6 @@ export const invokeCli = async ( value = module.resultSchema.parse(result); return value; }, - isTty: () => options.tty === true, name: manifest.plugin.name, ...(renderHost === undefined ? {} @@ -291,6 +295,7 @@ export const invokeCli = async ( }, }), signal, + terminal: harnessTerminal('cli', options.tty === true), version: manifest.plugin.version, writeErr: (text) => { err += text; }, writeOut: (text) => { out += text; }, diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index aaad23847..00caaf67e 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -475,6 +475,7 @@ export const openInMemoryMcpServer = async < host: transport.host, lineage: transport.lineage, session: transport.session, + terminal: transport.terminal, workspace: transport.workspace, ...context, providers, diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 820e93ed0..3caab0441 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -33,10 +33,12 @@ import type { } from '../cli-entry.ts'; import { createProviderProcessLifetime, type ProviderProcessLifetime } from '../routes/provider-execution.ts'; import type { CompiledCliCommand } from '../routes/types.ts'; +import type { AgentTerminal } from '../terminal-capability.ts'; import { AgentTestError, captured } from './errors.ts'; import { composeLayouts, loadLayoutChain, type LayoutChainTarget, type LoadedLayout } from './layouts.ts'; import { ROUTE_UNIT_PROOF_LEVEL, type AgentBundleTestManifest } from './manifest.ts'; import { claimProcessHit, mountProviders } from './providers.ts'; +import { routeKindTerminal } from './terminal.ts'; import { registeredManifestIdentity, registeredRouteLoader, @@ -1126,6 +1128,7 @@ export const prepareCliRenderHost = async ( projectRoot: renderer.available({ root }, 'derived'), }, host: renderer.unavailable('unsupported-surface'), + terminal: renderer.available(execution.terminal, 'native'), workspace: renderer.available({ root }, 'derived'), ...context, ...mounted.context, @@ -1188,7 +1191,7 @@ export interface PreparedScriptRenderHost { readonly close: () => Promise; readonly createSession: ( argv: readonly string[], - context: { readonly signal: AbortSignal }, + context: { readonly signal: AbortSignal; readonly terminal: AgentTerminal }, ) => GeneratedCliRenderSession; /** * Ends the render the way the generated executable's render worker ending @@ -1258,7 +1261,10 @@ export const prepareScriptRenderHost = async ( }; return Object.freeze({ close, - createSession: (argv: readonly string[], execution: { readonly signal: AbortSignal }): GeneratedCliRenderSession => { + createSession: ( + argv: readonly string[], + execution: { readonly signal: AbortSignal; readonly terminal: AgentTerminal }, + ): GeneratedCliRenderSession => { const invocation: AgentRenderInvocation = { kind: 'script', props: { input: argv as never, name: options.name }, @@ -1320,6 +1326,7 @@ export const prepareScriptRenderHost = async ( projectRoot: renderer.available({ root }, 'derived'), }, host: renderer.unavailable('unsupported-surface'), + terminal: renderer.available(execution.terminal, 'native'), workspace: renderer.available({ root }, 'derived'), ...context, ...state.context, @@ -1418,6 +1425,9 @@ const prepareRender = async ( limits: options.limits, renderer, requestInit: async (request) => ({ + // What the artifact's scope for this route kind mounts (#511): no + // terminal under MCP or a hook, the harness's piped shape otherwise. + terminal: renderer.available(routeKindTerminal(resolved.kind), 'derived'), ...context, ...mounted.context, // The render invocation is exactly what the generated Flight worker diff --git a/packages/agent-bundle/src/test/script.ts b/packages/agent-bundle/src/test/script.ts index 72bb5e383..34743b416 100644 --- a/packages/agent-bundle/src/test/script.ts +++ b/packages/agent-bundle/src/test/script.ts @@ -25,9 +25,11 @@ import { spawn } from 'node:child_process'; import { access } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { constants as osConstants } from 'node:os'; +import { pathToFileURL } from 'node:url'; import { format } from 'node:util'; import { scanEntryExports } from '../build/entry-exports.ts'; +import { terminalCapabilityRuntimePath } from '../build/entry-shell.ts'; import { metaModuleSpecifier } from '../build/meta.ts'; import { runGeneratedRenderedScript } from '../cli-entry.ts'; import type { CliRenderedEvent } from '../cli-entry.ts'; @@ -46,6 +48,7 @@ import { type HarnessOptionsArguments, type RenderRouteContextInit, } from './render.ts'; +import { harnessTerminal } from './terminal.ts'; import type { AgentRouteModule, RenderedRouteProvenance } from './types.ts'; export interface RunScriptOptionsBase { @@ -59,9 +62,12 @@ export interface RunScriptOptionsBase { */ readonly stdin?: string; /** - * Selects interactive rendered output explicitly. Generated executables use - * `process.stdout.isTTY`; the in-process harness defaults to piped output. - * Rendered scripts only. + * Selects interactive rendered output explicitly. Generated executables + * probe `process.stdout`; the in-process harness defaults to piped output. + * The same knob shapes the `request.terminal` the script observes (#511): + * a synthetic 80×24 basic-color terminal on both streams, or two + * color-free pipes. Rendered scripts only; a plain script's `main` receives + * the real child process's probe. */ readonly tty?: boolean; } @@ -301,6 +307,11 @@ registerHooks({ */ const envelopeSource = (execution: ScriptExecution): string => [ "import { pathToFileURL } from 'node:url';", + // The generated executable inlines the terminal probe through a bundler + // alias; the child imports the same module from the package instead. + ...(execution === 'main-envelope' + ? [`import { detectProcessTerminal } from ${JSON.stringify(pathToFileURL(terminalCapabilityRuntimePath()).href)};`] + : []), '', // A generated `scripts/.mjs` runs under plain `node `: the // loader flags this launch needs are the harness's, not the script's. @@ -313,7 +324,7 @@ const envelopeSource = (execution: ScriptExecution): string => [ "if (typeof main !== 'function') {", " throw new TypeError('Executable entry must export a main function: ' + source);", '}', - 'const code = await main(process.argv.slice(2));', + "const code = await main(process.argv.slice(2), Object.freeze({ terminal: detectProcessTerminal('script') }));", "if (typeof code === 'number') process.exitCode = code;", ] : []), @@ -616,9 +627,9 @@ export const runScript = async ( }, () => runGeneratedRenderedScript({ argv: frozenArgv, createSession: host.createSession, - isTty: () => options.tty === true, name: script.name, signal, + terminal: harnessTerminal('script', options.tty === true), writeErr: (text) => { err += text; }, writeOut: (text) => { out += text; }, })); diff --git a/packages/agent-bundle/src/test/terminal.ts b/packages/agent-bundle/src/test/terminal.ts new file mode 100644 index 000000000..32097d85c --- /dev/null +++ b/packages/agent-bundle/src/test/terminal.ts @@ -0,0 +1,43 @@ +import { noTerminal, type AgentTerminal, type AgentTerminalStream } from '../terminal-capability.ts'; +import type { RenderableRouteKind } from './types.ts'; + +/** + * The terminal capability (#511) an in-process harness level mounts. Nothing + * here probes the test runner's own streams: the values are synthetic and + * deterministic so a route test asserting on `request.terminal` reads the + * same answer under every runner and CI. `tty` selects the interactive + * shape the `tty` knob of `invokeCli` / `runScript` already stands for; the + * default is the piped shape a generated executable sees under `execFile`. + * A test that wants other values injects `context.terminal` through the + * same seam as every identity axis. + */ +export const harnessTerminal = (hostSurface: 'cli' | 'script', tty: boolean): AgentTerminal => { + const stream: AgentTerminalStream = tty + ? Object.freeze({ color: 'basic', columns: 80, kind: 'tty', rows: 24 }) + : Object.freeze({ color: 'none', kind: 'pipe' }); + return Object.freeze({ hostSurface, sharesTarget: tty, stderr: stream, stdout: stream }); +}; + +/** + * What the artifact's request scope for one route kind mounts when no CLI + * shell is involved: MCP tools, resources, and prompts have no terminal, an + * event route has none, and a CLI command or script rendered directly through + * `renderRoute` reads the harness's piped shape. + */ +export const routeKindTerminal = (kind: RenderableRouteKind): AgentTerminal => { + switch (kind) { + case 'prompt': + case 'resource': + case 'tool': + return noTerminal('mcp'); + case 'event-route': + return noTerminal('hook'); + case 'cli': + case 'script': + return harnessTerminal(kind, false); + default: { + const unreachable: never = kind; + throw new TypeError(`Unsupported route kind ${String(unreachable)}.`); + } + } +}; diff --git a/packages/agent-bundle/tests/cli-routes.test.ts b/packages/agent-bundle/tests/cli-routes.test.ts index 4569451d1..299f4f78e 100644 --- a/packages/agent-bundle/tests/cli-routes.test.ts +++ b/packages/agent-bundle/tests/cli-routes.test.ts @@ -12,6 +12,7 @@ import { projectCliDocumentToMarkdown, runGeneratedCliEntry, runGeneratedRenderedScript, + type AgentTerminal, type CliRenderedDocument, type CliRenderedEvent, type GeneratedCliRenderSession, @@ -1174,6 +1175,74 @@ describe('rendered command projection (#102 stage 3)', () => { expect(tty.stdout).toBe('\r\u001B[2Kauditing (1/2)\r\u001B[2KFound **2** books.\n'); }); + it('selects the output mode from the same terminal capability it hands the render host (#511)', async () => { + const terminal: AgentTerminal = { + hostSurface: 'cli', + sharesTarget: true, + stderr: { color: '256', columns: 132, kind: 'tty', rows: 50 }, + stdout: { color: '256', columns: 132, kind: 'tty', rows: 50 }, + }; + const seen: AgentTerminal[] = []; + const stdout: string[] = []; + const code = await runGeneratedCliEntry({ + argv: ['report', '/library'], + commands: [renderedCommand], + execute: async () => { + throw new Error('plain execute must not run for a rendered command'); + }, + name: 'curator', + render: (_command, _input, context) => { + seen.push(context.terminal); + return { close: async () => undefined, events: () => eventStream(events), validate: (value) => value }; + }, + terminal, + version: '1.2.3', + writeErr: () => undefined, + writeOut: (text) => void stdout.push(text), + }); + expect(code).toBe(0); + // An explicit capability wins over probing and drives the interactive mode. + expect(seen).toEqual([terminal]); + expect(stdout.join('')).toBe('\r\u001B[2Kauditing (1/2)\r\u001B[2KFound **2** books.\n'); + + // `--json` changes what stdout carries, never what the terminal is. + seen.length = 0; + await runGeneratedCliEntry({ + argv: ['report', '/library', '--json'], + commands: [renderedCommand], + execute: async () => undefined, + name: 'curator', + render: (_command, _input, context) => { + seen.push(context.terminal); + return { close: async () => undefined, events: () => eventStream(events), validate: (value) => value }; + }, + terminal, + version: '1.2.3', + writeErr: () => undefined, + writeOut: () => undefined, + }); + expect(seen).toEqual([terminal]); + + // The legacy `isTty` knob still shapes a consistent capability for stdout. + const legacy: AgentTerminal[] = []; + await runGeneratedCliEntry({ + argv: ['report', '/library'], + commands: [renderedCommand], + execute: async () => undefined, + isTty: () => false, + name: 'curator', + render: (_command, _input, context) => { + legacy.push(context.terminal); + return { close: async () => undefined, events: () => eventStream(events), validate: (value) => value }; + }, + version: '1.2.3', + writeErr: () => undefined, + writeOut: () => undefined, + }); + expect(legacy[0]?.hostSurface).toBe('cli'); + expect(legacy[0]?.stdout.kind).not.toBe('tty'); + }); + it('emits the canonical validated final value under --json', async () => { const json = await runRendered(['report', '/library', '--json'], { validate: (value) => ({ ...(value as Record), validated: true }), diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 900ab5160..7ea4cd541 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -95,13 +95,24 @@ describe('generated entry templates', () => { expect(source).not.toMatch(/^import[^\n]*curator\.ts/mu); }); - it('generates a process envelope that adopts numeric exit codes', () => { - const source = generatedExecutableEntrySource({ entrySource: '/proj/src/cli.ts', exportName: 'main' }); + it('generates a process envelope that adopts numeric exit codes and hands main the terminal capability (#511)', () => { + const source = generatedExecutableEntrySource({ entrySource: '/proj/src/cli.ts', exportName: 'main', hostSurface: 'cli' }); expect(source).toContain('import * as entry from "/proj/src/cli.ts"'); + expect(source).toContain(`import { detectProcessTerminal } from ${JSON.stringify(entryShellModule.terminalCapabilityRuntimeSpecifier)}`); expect(source).toContain('entry["main"]'); - expect(source).toContain('await main(process.argv.slice(2))'); + expect(source).toContain('await main(process.argv.slice(2), Object.freeze({ terminal: detectProcessTerminal("cli") }))'); expect(source).toContain("if (typeof code === 'number') process.exitCode = code;"); - expect(generatedExecutableEntrySource({ entrySource: '/e.ts', exportName: 'default' })).toContain('entry["default"]'); + // Artifact scripts default to the `script` surface; the envelope never loads the runtime. + const script = generatedExecutableEntrySource({ entrySource: '/e.ts', exportName: 'default' }); + expect(script).toContain('entry["default"]'); + expect(script).toContain('detectProcessTerminal("script")'); + expect(script).not.toContain('@agent-bundle/runtime'); + }); + + it('locates the dependency-free terminal probe the envelope aliases in', async () => { + const path = entryShellModule.terminalCapabilityRuntimePath(); + await expect(access(path)).resolves.toBeUndefined(); + expect(path.endsWith('terminal-capability.ts') || path.endsWith('terminal-capability.js')).toBe(true); }); it('defers installer filesystem URL conversion to the guarded runtime', () => { @@ -404,8 +415,9 @@ it('generates the warm react-server Flight worker separately from the MCP dispat '"hook:event-route:tool-after": Object.freeze({ event: "tool/after", id: "event:tool/after", kind: \'event-route\'', ); expect(source).toContain("lineage: message.lineage ?? unavailable('not-provided'),"); + expect(source).toContain("terminal: message.terminal ?? unavailable('not-provided'),"); expect(createHash('sha256').update(source).digest('hex')).toBe( - '7544ab8820a0784210464d71bb15f6de7999f521a6dc35a920136db613cbcd66', + '16bcae6386fbba1c664806a732e02373cc753d9c3baa976782218bdb88847773', ); expect(generate({ artifactEpoch: 'route-fixture@1.2.3', @@ -466,8 +478,41 @@ it('generates projected MCP commands with the same tool invocation and request c expect(source).toContain('request: { artifactEpoch: "route-fixture@1.2.3", kind: \'tool\', operationId: command.routeId, surface: command.mcp.tool }'); expect(source).toContain('props: { input: parsed }'); // The worker mounts providers from `message.invocation`, so the render - // message must carry the dispatched invocation (#319 review). - expect(source).toContain("worker.postMessage({ id, invocation, props, request, routeId, type: 'render' })"); + // message must carry the dispatched invocation (#319 review) and the + // executable's probed terminal (#511). + expect(source).toContain("worker.postMessage({ id, invocation, props, request, routeId, terminal, type: 'render' })"); + expect(source).toContain('terminal: context.terminal,'); +}); + +it('mounts the shell-probed terminal on every routed-CLI surface and forwards it under MCP and hooks (#511)', () => { + const plainRoute = { + config: {}, + id: 'cli:doctor', + kind: 'cli' as const, + provenance: { kind: 'conventional' as const, relativePath: 'src/cli/doctor.ts' }, + source: '/project/src/cli/doctor.ts', + }; + const bin = entryShellModule.generatedCliBinEntrySource({ + commands: [{ aliases: [], exitCode: 'zero', options: [], path: ['doctor'], rendered: false, routeId: plainRoute.id }], + plugin: { name: 'route-fixture', version: '1.2.3' }, + routes: [plainRoute], + }); + // Plain commands run in the executable itself: the shell's probe is the value. + expect(bin).toContain("terminal: available(context.terminal, 'native'),"); + + const worker = entryShellModule.generatedRenderedRouteWorkerSource({ routes: [plainRoute] }); + // A worker thread's own streams are pipes to the parent; it must never probe them. + expect(worker).toContain("terminal: message.terminal === undefined ? unavailable('not-provided') : available(message.terminal, 'native'),"); + expect(worker).not.toContain('detectProcessTerminal'); + + const flightWorker = entryShellModule.generatedRouteFlightWorkerSource({ + artifactEpoch: 'route-fixture@1.2.3', + routes: [], + serverName: 'curator', + }); + // The MCP host scope says `none`; the Flight worker forwards rather than guesses. + expect(flightWorker).toContain("terminal: message.terminal ?? unavailable('not-provided'),"); + expect(flightWorker).not.toContain('process.stdout.isTTY'); }); it('forwards the dispatched invocation to the rendered worker in every rendered surface', async () => { @@ -476,7 +521,8 @@ it('forwards the dispatched invocation to the rendered worker in every rendered routeId: 'script:report', workerFile: 'report-flight.mjs', }); - expect(generated).toContain("worker.postMessage({ id, invocation, props, request, routeId, type: 'render' })"); + expect(generated).toContain("worker.postMessage({ id, invocation, props, request, routeId, terminal, type: 'render' })"); + expect(generated).toContain('terminal: context.terminal,'); const factoryStart = generated.indexOf('const openRenderedSession'); const factoryEnd = generated.indexOf('\nawait runGeneratedRenderedScriptProcess'); const factory = generated.slice(factoryStart, factoryEnd) @@ -671,7 +717,7 @@ it('mounts deterministic per-request providers in rendered route workers', () => routeId: 'script:report', workerFile: 'report-flight.mjs', }); - expect(bridge).toContain("worker.postMessage({ id, invocation, props, request, routeId, type: 'render' })"); + expect(bridge).toContain("worker.postMessage({ id, invocation, props, request, routeId, terminal, type: 'render' })"); }); it('keeps the generated provider loop and the in-process execution helper identical', async () => { diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index fbcd8cafa..fccaee8ef 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -102,6 +102,50 @@ it('serves compiled routes and durable state across packed process restarts', as `${artifactManifest.project.revision}:claude:${dirname(dirname(resolve(entry)))}`; const deletedSource = await removeProjectSource({ projectRoot: project }); + // The artifact-hosted routed CLI and the `main`-envelope script probe + // their own process for `request.terminal` (#511): spawned here with one + // pipe per stream, so neither is a terminal and they share no target, + // while the informal color and size conventions still apply to pipes. + const colorAndSizeVariables = new Set(['CLICOLOR', 'CLICOLOR_FORCE', 'COLORTERM', 'COLUMNS', 'FORCE_COLOR', 'LINES', 'NO_COLOR']); + const plainEnv = Object.fromEntries(Object.entries(env).filter(([key]) => !colorAndSizeVariables.has(key))); + const probe = async (file: string, args: readonly string[], overrides: Readonly>): Promise => { + const { stdout } = await execFile(process.execPath, [file, ...args], { + cwd: project, + env: { ...plainEnv, TERM: 'xterm-256color', ...overrides }, + }); + return JSON.parse(stdout) as unknown; + }; + const cliBin = join(pluginRoot, 'bin', 'route-harness.mjs'); + const cliTerminal = async (overrides: Readonly>): Promise => + ((await probe(cliBin, ['harness', 'context', '--yes', '--json'], overrides)) as { readonly terminal: unknown }).terminal; + const pipe = { color: 'none', kind: 'pipe' }; + await expect(cliTerminal({})).resolves.toEqual({ + source: 'native', + state: 'available', + value: { hostSurface: 'cli', sharesTarget: false, stderr: pipe, stdout: pipe }, + }); + await expect(cliTerminal({ COLUMNS: '120', FORCE_COLOR: '3' })).resolves.toMatchObject({ + value: { + stderr: { color: 'truecolor', columns: 120, kind: 'pipe' }, + stdout: { color: 'truecolor', columns: 120, kind: 'pipe' }, + }, + }); + await expect(cliTerminal({ CLICOLOR_FORCE: '1', NO_COLOR: '1' })).resolves.toMatchObject({ + // CLICOLOR_FORCE forces color on for a pipe at the advertised depth ... + value: { stdout: { color: '256', kind: 'pipe' } }, + }); + await expect(cliTerminal({ NO_COLOR: '1' })).resolves.toMatchObject({ + // ... and NO_COLOR alone keeps it off. + value: { stdout: pipe }, + }); + await expect(probe(join(pluginRoot, 'scripts', 'checksum.mjs'), ['--terminal'], { FORCE_COLOR: '1', LINES: '50' })) + .resolves.toEqual({ + hostSurface: 'script', + sharesTarget: false, + stderr: { color: 'basic', kind: 'pipe', rows: 50 }, + stdout: { color: 'basic', kind: 'pipe', rows: 50 }, + }); + const firstSession = await openPackedMcpServer({ cwd: project, deletedSource, @@ -154,6 +198,17 @@ it('serves compiled routes and durable state across packed process restarts', as }, lineage: { reason: 'id-not-resolvable', state: 'unavailable' }, session: { reason: 'not-provided', state: 'unavailable' }, + // The packed server's stdout is the protocol wire: no terminal (#511). + terminal: { + source: 'derived', + state: 'available', + value: { + hostSurface: 'mcp', + sharesTarget: false, + stderr: { color: 'none', kind: 'none' }, + stdout: { color: 'none', kind: 'none' }, + }, + }, workspace: { source: 'derived', state: 'available', @@ -292,6 +347,8 @@ it('serves compiled routes and durable state across packed process restarts', as ); } expect(JSON.stringify(eventResponse)).toContain('actor unavailable:not-provided'); + // The event route ran in the shared runtime under a hook: no terminal (#511). + expect(JSON.stringify(eventResponse)).toContain('terminal available:derived hook/none/none'); expect(JSON.stringify(eventResponse)).toContain(noticeId); expect(JSON.stringify(eventResponse)).toContain('cross-process notice'); expect(secondSession.stderr()).not.toContain('"jsonrpc"'); diff --git a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts index 1b3bbdffc..7cb51eb05 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts @@ -131,4 +131,56 @@ describe('the CLI dispatch level', () => { expect(invocationCwd).not.toBe(run.provenance.projectRoot); expect(observed).toEqual({ projectRoot: invocationCwd, workspace: invocationCwd }); }); + + describe('the terminal capability (#511)', () => { + const piped = { + hostSurface: 'cli', + sharesTarget: false, + stderr: { color: 'none', kind: 'pipe' }, + stdout: { color: 'none', kind: 'pipe' }, + }; + const interactive = { + hostSurface: 'cli', + sharesTarget: true, + stderr: { color: 'basic', columns: 80, kind: 'tty', rows: 24 }, + stdout: { color: 'basic', columns: 80, kind: 'tty', rows: 24 }, + }; + + it('mounts the piped shape for a plain command by default and the interactive shape under tty', async () => { + const observe = async (tty: boolean): Promise => { + let observed: unknown; + const run = await invokeCli(['inventory', 'fiction'], { + context: { progress: { report: async () => { observed = (await agent()).terminal; } } }, + tty, + }); + expect(run.exitCode).toBe(0); + return observed; + }; + expect(await observe(false)).toEqual({ source: 'native', state: 'available', value: piped }); + expect(await observe(true)).toEqual({ source: 'native', state: 'available', value: interactive }); + }); + + it('reports the executable surface, not the MCP one, for a projected tool rendered through the CLI', async () => { + const run = await invokeCli(['harness', 'context', '--yes', '--json']); + expect(run.exitCode).toBe(0); + // `--json` changes what stdout carries, never what the terminal is. + expect(cliJson(run)).toMatchObject({ terminal: { source: 'native', state: 'available', value: piped } }); + + const tty = await invokeCli(['harness', 'context', '--yes', '--json'], { tty: true }); + expect(cliJson(tty)).toMatchObject({ terminal: { source: 'native', state: 'available', value: interactive } }); + }); + + it('lets a test inject the capability through the context seam', async () => { + const injected = { + hostSurface: 'cli' as const, + sharesTarget: false, + stderr: { color: 'truecolor' as const, columns: 200, kind: 'tty' as const, rows: 50 }, + stdout: { color: 'none' as const, kind: 'pipe' as const }, + }; + const run = await invokeCli(['harness', 'context', '--yes', '--json'], { + context: { terminal: { source: 'native', state: 'available', value: injected } }, + }); + expect(cliJson(run)).toMatchObject({ terminal: { source: 'native', state: 'available', value: injected } }); + }); + }); }); diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index 6ab4274a6..a830e0f84 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -106,6 +106,17 @@ describe('the in-memory MCP projection level', () => { }, lineage: { reason: 'not-provided', state: 'unavailable' }, session: { reason: 'not-provided', state: 'unavailable' }, + // The generated server's stdout is the protocol wire: no terminal, never probed (#511). + terminal: { + source: 'derived', + state: 'available', + value: { + hostSurface: 'mcp', + sharesTarget: false, + stderr: { color: 'none', kind: 'none' }, + stdout: { color: 'none', kind: 'none' }, + }, + }, workspace: { source: 'derived', state: 'available', diff --git a/packages/agent-bundle/tests/projection/script-dispatch.test.ts b/packages/agent-bundle/tests/projection/script-dispatch.test.ts index 1d82b667d..cfa320c34 100644 --- a/packages/agent-bundle/tests/projection/script-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/script-dispatch.test.ts @@ -16,6 +16,8 @@ const summaryValue = (...argv: string[]) => ({ invocation: 'script', stateMounted: true, surface: 'summary', + // The harness renders as a piped executable unless `tty` says otherwise (#511). + terminal: 'script/pipe/pipe', }); describe('the compiled script inventory', () => { @@ -126,6 +128,8 @@ describe('rendered scripts at the script dispatch level', () => { expect(run.stdout).toContain('\r\u001B[2Kcollecting arguments (1/2)'); expect(run.stdout).toContain('\r\u001B[2Ksummary ready (2/2)'); expect(run.stdout.endsWith('# Summary\n\n1 argument(s).\n\nsurface: summary\n')).toBe(true); + // The same knob that picked the interactive mode is what the script observes (#511). + expect(run.value).toEqual({ ...summaryValue('alpha'), terminal: 'script/tty/tty' }); }); it('reserves --json for the canonical value and passes every other argument through', async () => { @@ -308,6 +312,27 @@ describe('plain scripts at the script dispatch level', () => { expect(run.stderr).toBe('No arguments to checksum.\n'); }); + it('hands main the terminal capability the envelope probed from its own process (#511)', async () => { + const run = await runScript('checksum', ['--terminal']); + + expect(run.exitCode).toBe(0); + // The harness captures both streams through pipes, and the child's stdout + // and stderr are two different pipes, so neither is a terminal and they + // do not share a target. The child inherits this runner's environment, so + // color is whatever FORCE_COLOR/NO_COLOR say here (CI runners force it + // on); the env-controlled color proof is the packed level's. + const probed = JSON.parse(run.stdout) as { readonly stderr: { readonly color: string }; readonly stdout: { readonly color: string } }; + expect(probed).toMatchObject({ + hostSurface: 'script', + sharesTarget: false, + stderr: { kind: 'pipe' }, + stdout: { kind: 'pipe' }, + }); + expect(probed.stdout).not.toHaveProperty('columns'); + expect(['none', 'basic', '256', 'truecolor']).toContain(probed.stdout.color); + expect(probed.stderr.color).toBe(probed.stdout.color); + }); + it('adopts an assigned process.exitCode when main returns nothing', async () => { const previous = process.exitCode; const run = await runScript('checksum', ['--exit-code-property']); diff --git a/packages/agent-bundle/tests/route-unit/render-route.test.ts b/packages/agent-bundle/tests/route-unit/render-route.test.ts index baa270ff4..0a513377c 100644 --- a/packages/agent-bundle/tests/route-unit/render-route.test.ts +++ b/packages/agent-bundle/tests/route-unit/render-route.test.ts @@ -10,6 +10,13 @@ import { testManifest } from '../../src/test/registry.ts'; const workspace = { source: 'native', state: 'available', value: { root: '/tmp/harness-library' } } as never; const notProvided = { reason: 'not-provided', state: 'unavailable' }; +/** What every MCP request scope reports for `request.terminal` (#511). */ +const noTerminal = { + hostSurface: 'mcp', + sharesTarget: false, + stderr: { color: 'none', kind: 'none' }, + stdout: { color: 'none', kind: 'none' }, +}; /** The harness error one render rejected with; a resolved render is itself a failure. */ const rejection = async (render: Promise): Promise => { @@ -77,10 +84,36 @@ describe('renderRoute through the real renderer', () => { host: notProvided, lineage: notProvided, session: notProvided, + // An MCP tool has no terminal, on every surface that serves it (#511). + terminal: { source: 'derived', state: 'available', value: noTerminal }, workspace: notProvided, }); }); + it('lets a test inject the terminal capability through the context seam (#511)', async () => { + const injected = await renderRoute('tool:harness/context', { + context: { + terminal: { + source: 'native', + state: 'available', + value: { + hostSurface: 'cli', + sharesTarget: true, + stderr: { color: 'truecolor', columns: 100, kind: 'tty', rows: 30 }, + stdout: { color: 'truecolor', columns: 100, kind: 'tty', rows: 30 }, + }, + }, + }, + }); + expect(injected.result).toMatchObject({ + terminal: { + source: 'native', + state: 'available', + value: { hostSurface: 'cli', stdout: { color: 'truecolor', columns: 100, kind: 'tty', rows: 30 } }, + }, + }); + }); + it('preserves injected identity values and their observation sources', async () => { const lineage = { conversation: 'agent-child', @@ -105,6 +138,7 @@ describe('renderRoute through the real renderer', () => { host: { source: 'native', state: 'available', value: { name: 'route-unit-host' } }, lineage: { source: 'derived', state: 'available', value: lineage }, session: { source: 'native', state: 'available', value: { sessionId: 'route-unit-session' } }, + terminal: { source: 'derived', state: 'available', value: noTerminal }, workspace: { source: 'derived', state: 'available', value: { root: '/tmp/route-unit' } }, }); }); @@ -127,6 +161,7 @@ describe('renderRoute through the real renderer', () => { host: notProvided, lineage: notProvided, session: notProvided, + terminal: { source: 'derived', state: 'available', value: noTerminal }, workspace: notProvided, }); }); @@ -309,6 +344,8 @@ describe('renderRoute through the real renderer', () => { .toHaveStatus('success') .toContainMarkdown('Observed tool/after from claude.') .toContainContext('actor unavailable:not-provided') + // An event route runs under a hook: no terminal, never probed (#511). + .toContainContext('terminal available:derived hook/none/none') .toHaveValue(undefined); }); diff --git a/packages/agent-bundle/tests/terminal-capability.test.ts b/packages/agent-bundle/tests/terminal-capability.test.ts new file mode 100644 index 000000000..606c4523c --- /dev/null +++ b/packages/agent-bundle/tests/terminal-capability.test.ts @@ -0,0 +1,132 @@ +import { openSync, closeSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; + +import { describe, expect, it } from '@rstest/core'; + +import { + detectProcessTerminal, + noTerminal, + sharesOutputTarget, + terminalColor, + type TerminalStreamProbe, +} from '../src/terminal-capability.ts'; + +/** A descriptor number no process holds open: `fstat` on it fails with EBADF. */ +const CLOSED_FD = 1_000_003; + +const tty = (fd: number, columns = 120, rows = 40): TerminalStreamProbe => ({ columns, fd, isTTY: true, rows }); + +describe('terminal color detection (#511)', () => { + it('follows the informal standards in their usual precedence', () => { + const xterm = { TERM: 'xterm-256color' }; + // A terminal renders at the depth TERM/COLORTERM advertise; a pipe renders none. + expect(terminalColor(xterm, true)).toBe('256'); + expect(terminalColor({ COLORTERM: 'truecolor', TERM: 'xterm' }, true)).toBe('truecolor'); + expect(terminalColor({ TERM: 'xterm' }, true)).toBe('basic'); + expect(terminalColor({}, true)).toBe('basic'); + expect(terminalColor(xterm, false)).toBe('none'); + // FORCE_COLOR decides outright, with Node's own reading of its value. + expect(terminalColor({ ...xterm, FORCE_COLOR: '' }, false)).toBe('basic'); + expect(terminalColor({ ...xterm, FORCE_COLOR: '1' }, false)).toBe('basic'); + expect(terminalColor({ ...xterm, FORCE_COLOR: 'true' }, false)).toBe('basic'); + expect(terminalColor({ ...xterm, FORCE_COLOR: '2' }, false)).toBe('256'); + expect(terminalColor({ ...xterm, FORCE_COLOR: '3' }, false)).toBe('truecolor'); + expect(terminalColor({ ...xterm, FORCE_COLOR: '0' }, true)).toBe('none'); + expect(terminalColor({ ...xterm, FORCE_COLOR: 'false' }, true)).toBe('none'); + // FORCE_COLOR beats NO_COLOR either way. + expect(terminalColor({ FORCE_COLOR: '1', NO_COLOR: '1' }, false)).toBe('basic'); + expect(terminalColor({ FORCE_COLOR: '0', NO_COLOR: '' }, true)).toBe('none'); + // CLICOLOR_FORCE forces color on for a pipe, at the advertised depth; NO_COLOR and CLICOLOR=0 force it off. + expect(terminalColor({ ...xterm, CLICOLOR_FORCE: '1' }, false)).toBe('256'); + expect(terminalColor({ CLICOLOR_FORCE: '0', TERM: 'xterm' }, false)).toBe('none'); + expect(terminalColor({ ...xterm, NO_COLOR: '1' }, true)).toBe('none'); + expect(terminalColor({ ...xterm, NO_COLOR: '' }, true)).toBe('256'); + expect(terminalColor({ ...xterm, CLICOLOR: '0' }, true)).toBe('none'); + // A dumb terminal cannot render color. + expect(terminalColor({ TERM: 'dumb' }, true)).toBe('none'); + expect(terminalColor({ CLICOLOR_FORCE: '1', TERM: 'dumb' }, false)).toBe('basic'); + }); +}); + +describe('process terminal detection (#511)', () => { + it('reports a terminal with its size and color, and lets COLUMNS/LINES override the size', () => { + const env = { COLORTERM: 'truecolor', TERM: 'xterm-256color' }; + const probed = detectProcessTerminal('cli', { env, stderr: tty(2), stdout: tty(1) }); + expect(probed.hostSurface).toBe('cli'); + expect(probed.stdout).toEqual({ color: 'truecolor', columns: 120, kind: 'tty', rows: 40 }); + expect(probed.stderr).toEqual({ color: 'truecolor', columns: 120, kind: 'tty', rows: 40 }); + expect(Object.isFrozen(probed)).toBe(true); + expect(Object.isFrozen(probed.stdout)).toBe(true); + + const overridden = detectProcessTerminal('script', { env: { ...env, COLUMNS: '200', LINES: '60' }, stderr: tty(2), stdout: tty(1) }); + expect(overridden.stdout).toMatchObject({ columns: 200, rows: 60 }); + expect(overridden.stderr).toMatchObject({ columns: 200, rows: 60 }); + // Garbage overrides are no override. + const garbage = detectProcessTerminal('script', { env: { ...env, COLUMNS: 'wide', LINES: '0' }, stderr: tty(2), stdout: tty(1) }); + expect(garbage.stdout).toMatchObject({ columns: 120, rows: 40 }); + }); + + it('reports any other open descriptor as a color-free pipe, sized only by an explicit override', async () => { + const directory = await mkdtemp(join(tmpdir(), 'agent-bundle-terminal-')); + const fd = openSync(join(directory, 'out.log'), 'w'); + try { + const env = { TERM: 'xterm-256color' }; + const piped = detectProcessTerminal('cli', { + env, + stderr: { columns: 80, fd, isTTY: false, rows: 24 }, + stdout: { columns: 80, fd, isTTY: false, rows: 24 }, + }); + expect(piped.stdout).toEqual({ color: 'none', kind: 'pipe' }); + expect(piped.stderr).toEqual({ color: 'none', kind: 'pipe' }); + // Both descriptors name one file: `2>&1` from inside the process. + expect(piped.sharesTarget).toBe(true); + + const forced = detectProcessTerminal('cli', { + env: { ...env, COLUMNS: '100', FORCE_COLOR: '3' }, + stderr: { fd, isTTY: false }, + stdout: { fd, isTTY: false }, + }); + expect(forced.stdout).toEqual({ color: 'truecolor', columns: 100, kind: 'pipe' }); + } finally { + closeSync(fd); + await rm(directory, { force: true, recursive: true }); + } + }); + + it('reports a closed descriptor as none and never colors it', () => { + const closed = detectProcessTerminal('script', { + env: { FORCE_COLOR: '3' }, + stderr: { fd: CLOSED_FD, isTTY: false }, + stdout: { fd: CLOSED_FD, isTTY: false }, + }); + expect(closed.stdout).toEqual({ color: 'none', kind: 'none' }); + expect(closed.stderr).toEqual({ color: 'none', kind: 'none' }); + expect(closed.sharesTarget).toBe(false); + expect(sharesOutputTarget(CLOSED_FD, CLOSED_FD)).toBe(false); + }); + + it('probes this process by default', () => { + const probed = detectProcessTerminal('cli'); + expect(probed.hostSurface).toBe('cli'); + // The test runner's streams are whatever they are; the shape is what is pinned. + expect(['tty', 'pipe', 'none']).toContain(probed.stdout.kind); + expect(['tty', 'pipe', 'none']).toContain(probed.stderr.kind); + expect(typeof probed.sharesTarget).toBe('boolean'); + }); +}); + +describe('terminal-free surfaces (#511)', () => { + it('report none on both streams without probing anything', () => { + for (const surface of ['mcp', 'hook', 'workbench'] as const) { + expect(noTerminal(surface)).toEqual({ + hostSurface: surface, + sharesTarget: false, + stderr: { color: 'none', kind: 'none' }, + stdout: { color: 'none', kind: 'none' }, + }); + expect(Object.isFrozen(noTerminal(surface))).toBe(true); + } + }); +}); diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index 33fb4e3d1..85b89c1be 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -8,9 +8,10 @@ import type { } from './notices/contract.js'; import type { AgentStateHandle } from './state/contract.js'; -// Bumped to 3 when `lineage` joined the handle shape: a realm that already -// holds an older store must fail closed rather than hand out handles without it. -export const AGENT_REQUEST_STORE_VERSION = 3; +// Bumped to 3 when `lineage` joined the handle shape and to 4 when `terminal` +// did: a realm that already holds an older store must fail closed rather than +// hand out handles without them. +export const AGENT_REQUEST_STORE_VERSION = 4; const STORE_SYMBOL = Symbol.for('@agent-bundle/runtime/request-store'); @@ -128,6 +129,55 @@ export interface AgentLineage { readonly subagent?: AgentLineageSubagent; } +/** + * What one of the process's output streams is, as far as the request can + * honestly tell: `tty` is an interactive terminal, `pipe` is any other open + * descriptor (a pipe, a file, a socket, `/dev/null`), and `none` means no + * human-facing stream exists for the route on this surface — an MCP server's + * stdout is the protocol wire and a hook's is its host envelope. + */ +export type AgentTerminalStreamKind = 'tty' | 'pipe' | 'none'; + +/** The color depth a stream renders: `basic` is 16 colors, `256` the xterm palette, `truecolor` 24-bit. */ +export type AgentTerminalColor = 'none' | 'basic' | '256' | 'truecolor'; + +/** + * The projection the request is running under. The routed CLI (`cli`), a + * rendered or plain script (`script`), and the Workbench (`workbench`) each + * own a process whose streams can be probed; a generated MCP server (`mcp`) + * and an event route (`hook`) never have a terminal, whatever their + * descriptors are, so those two always report `none`. + */ +export type AgentTerminalSurface = 'cli' | 'mcp' | 'hook' | 'script' | 'workbench'; + +export interface AgentTerminalStream { + /** Present when the stream is a terminal or `COLUMNS` overrides it. */ + readonly columns?: number; + readonly color: AgentTerminalColor; + readonly kind: AgentTerminalStreamKind; + /** Present when the stream is a terminal or `LINES` overrides it. */ + readonly rows?: number; +} + +/** + * The terminal capability of the process the route runs in (#511): what + * stdout and stderr are, whether they render color (per `NO_COLOR`, + * `FORCE_COLOR`, `CLICOLOR`, `CLICOLOR_FORCE`, `TERM`, and `COLORTERM`), and + * how wide they are (`COLUMNS`/`LINES` override the terminal's report). It is + * information only — never a writer — computed once per invocation by the + * framework shell with the same rules that pick the CLI output mode, so a + * route's own output agrees with the framework's. Under the routed CLI and + * rendered scripts, machine output owns stdout: `stdout` describes where the + * rendered document lands, `stderr` the channel a route may write to itself. + */ +export interface AgentTerminal { + readonly hostSurface: AgentTerminalSurface; + /** Whether stdout and stderr name the same open file (`2>&1`, one shared terminal). */ + readonly sharesTarget: boolean; + readonly stderr: AgentTerminalStream; + readonly stdout: AgentTerminalStream; +} + export interface AgentFilesystemAuthority { readonly roots: readonly string[]; } @@ -302,6 +352,12 @@ export interface AgentRequestContext { * host fields; `unavailable` carries the per-host reason. */ readonly lineage: Observed; + /** + * The terminal capability of the process (#511), probed by the routed CLI + * and script shells and reported as `none` under MCP and hooks; `unavailable` + * when the host wiring mounted none. + */ + readonly terminal: Observed; readonly capabilities: AgentRequestCapabilities; readonly progress: AgentProgressReporter; readonly signal: AbortSignal; @@ -348,6 +404,7 @@ export interface AgentRequestInitBase { readonly signal?: AbortSignal; /** Request-bound state handle from `createAgentStateHandle` (subpath `./state`). */ readonly state?: AgentStateHandle; + readonly terminal?: Observed; readonly workspace?: Observed; } @@ -446,6 +503,7 @@ interface FrozenValues { readonly session: Observed; readonly signal: AbortSignal; readonly state: AgentStateHandle | undefined; + readonly terminal: Observed; readonly workspace: Observed; } @@ -513,6 +571,9 @@ const createHandle = (lease: Lease): AgentRequestContext => Object.freeze({ get lineage() { return open(lease).lineage; }, + get terminal() { + return open(lease).terminal; + }, get capabilities() { return open(lease).capabilities; }, @@ -583,6 +644,7 @@ export const runAgentRequest = async ( const lineage = snapshotObserved(init.lineage ?? unavailable()); const session = snapshotObserved(init.session ?? unavailable()); const signal = init.signal ?? new AbortController().signal; + const terminal = snapshotObserved(init.terminal ?? unavailable()); const workspace = snapshotObserved(init.workspace ?? unavailable()); const noticeLease: AgentNoticeRequestLease | undefined = init.noticeLedger === undefined ? undefined @@ -604,6 +666,7 @@ export const runAgentRequest = async ( session, signal, state: init.state, + terminal, workspace, }); const lease = new Lease(values); diff --git a/packages/rsc-runtime/src/cli.ts b/packages/rsc-runtime/src/cli.ts index 60cc0cd3b..aae46e479 100644 --- a/packages/rsc-runtime/src/cli.ts +++ b/packages/rsc-runtime/src/cli.ts @@ -1,8 +1,15 @@ import type { RscApplication } from './application.js'; -import { available, runAgentRequest, unavailable } from './agent-request.js'; +import { available, runAgentRequest, unavailable, type AgentTerminal } from './agent-request.js'; export interface RscCliOptions { readonly signal?: AbortSignal; + /** + * The terminal capability to mount as `request.terminal` (#511). This + * adapter owns no probe — the generated routed-CLI shell does — so a host + * that knows its streams passes the value; omitted, the axis is honestly + * `unavailable` (`not-provided`). + */ + readonly terminal?: AgentTerminal; readonly write?: (value: string) => void; } @@ -48,6 +55,7 @@ export const runRscCli = async ( surface: cli.name, }, signal, + ...(options.terminal === undefined ? {} : { terminal: available(options.terminal, 'native') }), workspace: available({ root: cwd }, 'derived'), }, async () => operation.execute(cli.parse(commandArguments), { signal })); signal.throwIfAborted(); diff --git a/packages/rsc-runtime/src/mcp-server.ts b/packages/rsc-runtime/src/mcp-server.ts index 7b61ff852..7f5e33e55 100644 --- a/packages/rsc-runtime/src/mcp-server.ts +++ b/packages/rsc-runtime/src/mcp-server.ts @@ -1,9 +1,17 @@ import { McpServer as ProtocolMcpServer } from '@modelcontextprotocol/server'; import type { RscApplication } from './application.js'; -import { available, runAgentRequest } from './agent-request.js'; +import { available, runAgentRequest, type AgentTerminal } from './agent-request.js'; import { lowerMcpResult } from './lower-mcp.js'; +/** An MCP server's stdout is the protocol wire and its stderr the host's log: no terminal, never probed (#511). */ +const mcpTerminal: AgentTerminal = Object.freeze({ + hostSurface: 'mcp', + sharesTarget: false, + stderr: Object.freeze({ color: 'none', kind: 'none' }), + stdout: Object.freeze({ color: 'none', kind: 'none' }), +}); + export const createRscMcpServer = ( application: Readonly, serverName: string, @@ -53,6 +61,7 @@ export const createRscMcpServer = ( ? { session: available({ sessionId: context.sessionId }, 'native') } : {}), signal: context.mcpReq.signal, + terminal: available(mcpTerminal, 'derived'), }, async () => { const result = await operation.execute(input, { signal: context.mcpReq.signal }); return lowerMcpResult(operation.render(result)); diff --git a/packages/rsc-runtime/src/plugin.ts b/packages/rsc-runtime/src/plugin.ts index b52e9247c..a37925b01 100644 --- a/packages/rsc-runtime/src/plugin.ts +++ b/packages/rsc-runtime/src/plugin.ts @@ -47,6 +47,11 @@ export type { AgentServiceRegistry, AgentScriptInvocationProps, AgentSessionIdentity, + AgentTerminal, + AgentTerminalColor, + AgentTerminalStream, + AgentTerminalStreamKind, + AgentTerminalSurface, AgentToolInvocationProps, AgentWorkbenchInvocationProps, AgentWorkspaceIdentity, diff --git a/packages/rsc-runtime/tests/agent-request.test.ts b/packages/rsc-runtime/tests/agent-request.test.ts index 3b27a0201..bab1227f5 100644 --- a/packages/rsc-runtime/tests/agent-request.test.ts +++ b/packages/rsc-runtime/tests/agent-request.test.ts @@ -95,10 +95,27 @@ describe('agent request store', () => { expect(context.session).toEqual(unavailable()); expect(context.actor).toEqual(unavailable()); expect(context.workspace).toEqual(unavailable()); + expect(context.terminal).toEqual(unavailable()); expect(Object.hasOwn(context.host, 'value')).toBe(false); }); }); + it('exposes the mounted terminal capability as a frozen Observed axis (#511)', async () => { + const terminal = { + hostSurface: 'cli' as const, + sharesTarget: true, + stderr: { color: 'basic' as const, columns: 120, kind: 'tty' as const, rows: 40 }, + stdout: { color: 'basic' as const, columns: 120, kind: 'tty' as const, rows: 40 }, + }; + await runAgentRequest({ ...init('cli'), terminal: available(terminal, 'native') }, async () => { + const context = await agent(); + expect(context.terminal).toEqual({ source: 'native', state: 'available', value: terminal }); + if (context.terminal.state !== 'available') throw new Error('expected an available terminal'); + expect(Object.isFrozen(context.terminal.value)).toBe(true); + expect(Object.isFrozen(context.terminal.value.stdout)).toBe(true); + }); + }); + it('snapshots nested capability lists so caller mutation cannot leak into the request', async () => { const roots = ['/tmp/project']; const allow = ['example.test']; @@ -334,6 +351,9 @@ describe('entrypoint bindings', () => { kind: context.invocation.kind, operationId: context.invocation.operationId, surface: context.invocation.surface, + terminal: context.terminal.state === 'available' + ? `${context.terminal.source} ${context.terminal.value.hostSurface}/${context.terminal.value.stdout.kind}/${context.terminal.value.stderr.kind}` + : `unavailable:${context.terminal.reason}`, }; }, id: 'status', @@ -353,6 +373,7 @@ describe('entrypoint bindings', () => { kind: z.enum(['tool', 'event', 'cli', 'script', 'workbench']), operationId: z.string().optional(), surface: z.string().optional(), + terminal: z.string(), }).strict(), }); const application = defineRscApplication({ @@ -373,8 +394,22 @@ describe('entrypoint bindings', () => { kind: 'cli', operationId: 'status', surface: 'status', + // The adapter owns no probe: without a caller-supplied terminal the axis is honestly absent (#511). + terminal: 'unavailable:not-provided', }); await expect(agent()).rejects.toMatchObject({ code: 'outside-invocation' }); + + const probed: string[] = []; + await runRscCli(application, ['status'], { + terminal: { + hostSurface: 'cli', + sharesTarget: true, + stderr: { color: 'basic', columns: 80, kind: 'tty', rows: 24 }, + stdout: { color: 'basic', columns: 80, kind: 'tty', rows: 24 }, + }, + write: (value) => probed.push(value), + }); + expect(JSON.parse(probed.join(''))).toMatchObject({ terminal: 'native cli/tty/tty' }); }); it('installs a tool invocation for createRscMcpServer', async () => { @@ -389,6 +424,8 @@ describe('entrypoint bindings', () => { kind: 'tool', operationId: 'status', surface: 'runtime_status', + // An MCP server has no terminal, whatever its descriptors are (#511). + terminal: 'derived mcp/none/none', }); await expect(agent()).rejects.toMatchObject({ code: 'outside-invocation' }); }); diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 2bd89c9a6..a0d667ac2 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -115,6 +115,45 @@ siblings claimed blind, places a start no spawn window could, and moves a child filed under the wrong parent. There is deliberately no operator or user identity axis: the framework never reads or surfaces who the human behind a host session is. +### The terminal capability + +The handle's last axis is `terminal`, an `Observed`: what the process's output +streams are, so a route that paints its own stderr or sizes its own table never probes +`process.stdout.isTTY` or reads `FORCE_COLOR` itself. The framework shell computes it once per +invocation with the same rules that select the CLI output mode, so plugin output and framework +output agree. It is information only — never a writer — and it changes nothing about what +`Agent.*` components render. + +```ts +interface AgentTerminal { + hostSurface: 'cli' | 'mcp' | 'hook' | 'script' | 'workbench'; + stdout: { kind: 'tty' | 'pipe' | 'none'; color: 'none' | 'basic' | '256' | 'truecolor'; columns?: number; rows?: number }; + stderr: { kind: 'tty' | 'pipe' | 'none'; color: 'none' | 'basic' | '256' | 'truecolor'; columns?: number; rows?: number }; + sharesTarget: boolean; // fd 1 and fd 2 name one open file (`2>&1`, one shared terminal) +} +``` + +Color follows the informal standards in their usual precedence: `FORCE_COLOR` decides outright +when set (`0`/`false` off; empty, `1`, or `true` basic; `2` 256; `3` truecolor), `CLICOLOR_FORCE` +forces color on even for a pipe, `NO_COLOR` (any non-empty value) and `CLICOLOR=0` force it off, +`TERM=dumb` renders none, and otherwise a terminal renders at the depth `COLORTERM`/`TERM` +advertise while a pipe renders none. `COLUMNS`/`LINES` override the reported size. + +| Where the route runs | `hostSurface` | `stdout` / `stderr` | +| --- | --- | --- | +| Routed CLI executable — plain, rendered, or projected MCP command | `cli` | Probed from the executable's own process. Machine output owns stdout, so `stdout` says where the document lands and `stderr` is the channel a route may write to. | +| Rendered script (`src/scripts/.tsx`) | `script` | Probed, as above. | +| Generated MCP server | `mcp` | `none` on both — stdout is the protocol wire, stderr the host's log. Never probed, never guessed. | +| Event route (shared runtime or standalone hook) | `hook` | `none` on both — stdout is the host's hook envelope. | +| Workbench lifecycle replay | `workbench` | `none` on both. | +| `defineRscApplication` adapters | `mcp` / `cli` | `createRscMcpServer` mounts `none` on both; `runRscCli` owns no probe and mounts the `terminal` its caller passes in the options, else `unavailable` (`not-provided`). | +| A custom host that mounts none | — | `unavailable` (`not-provided`). | + +A plain `main`-exporting script or bin has no request scope; its envelope passes the same value +as the second argument of `main` (see [Package entries](./package-entries.mdx#the-executable-envelope)). +In tests, the `tty` knob of `invokeCli` and `runScript` shapes a deterministic synthetic value, +and `context.terminal` injects any other one through the same seam as every identity axis. + ## Streaming and progress A route streams by rendering React `Suspense`: the shell goes out first with the fallback in diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index 9701ccf95..c420d4d5e 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -58,16 +58,24 @@ function, receives the generated process envelope: ```ts // src/cli.ts — the whole CLI entry a consumer writes -export const main = async (argv: readonly string[]): Promise => { - // ... +import type { ExecutableMainContext } from 'agent-bundle'; + +export const main = async (argv: readonly string[], { terminal }: ExecutableMainContext): Promise => { + if (terminal.stderr.color !== 'none') { /* paint progress on stderr */ } return 0; }; ``` -The envelope awaits `main(process.argv.slice(2))`, adopts a numeric return as the process exit -code, and lets an escaped rejection surface through Node's top-level failure path (stack to -stderr, exit code 1). Self-executing modules with no `main` export bundle directly, byte for -byte. +The envelope awaits `main(process.argv.slice(2), { terminal })`, adopts a numeric return as the +process exit code, and lets an escaped rejection surface through Node's top-level failure path +(stack to stderr, exit code 1). `terminal` is the process's +[terminal capability](./mcp.mdx#the-terminal-capability): TTY-ness, color, and size for stdout +and stderr, plus whether the two share one target, probed once before `main` runs by a +dependency-free module the envelope inlines — a plain script or bin loads no runtime for it. +`hostSurface` is `cli` for a package bin and `script` for an artifact script, so a module shipped +on both surfaces sees the one it was launched from. A `main` declared with one parameter keeps +working; the second argument is simply unread. Self-executing modules with no `main` export +bundle directly, byte for byte, and receive no probe. ## The routed CLI diff --git a/website/docs/en/guide/authoring/scripts-assets.mdx b/website/docs/en/guide/authoring/scripts-assets.mdx index 9f56101be..88688766b 100644 --- a/website/docs/en/guide/authoring/scripts-assets.mdx +++ b/website/docs/en/guide/authoring/scripts-assets.mdx @@ -78,6 +78,13 @@ Rendered scripts and rendered routed-CLI commands share one output contract: written as non-MCP bytes to an MCP server's stdout. Diagnostics stay on stderr; machine output owns stdout. +The mode is selected from the executable's probed +[terminal capability](./mcp.mdx#the-terminal-capability), and the component reads the same +value from `(await agent()).terminal` (`hostSurface: 'script'`) — so a script that writes its +own progress to stderr colors and sizes it exactly as the framework would, without probing +`process.stdout` itself. A plain `main`-exporting script receives the same value as +`main`'s second argument (see [Package entries](./package-entries.mdx#the-executable-envelope)). + ### Running a script `script.run` is a production-mounted, trusted-local Workbench Playground operation. It runs only diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 027044f3c..b3b7fee3b 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -105,6 +105,41 @@ start 匹配到最新一个未被认领的 spawn 调用)、`confirmed`(宿 窗口无法安放的 start,并把窗口挂错父节点的子代理挪到正确的父节点下。框架刻意不提供操作者或用户身份轴:它永远不会读取或暴露宿主 会话背后的人是谁。 +### 终端能力 + +句柄的最后一个轴是 `terminal`,一个 `Observed`:描述进程的输出流是什么,因此需要自己给 +stderr 上色或给表格定宽的路由,再也不必自行探测 `process.stdout.isTTY` 或读取 `FORCE_COLOR`。框架外壳 +在每次调用时只计算一次,所用规则与选择 CLI 输出模式的规则完全相同,所以插件输出与框架输出总是一致。 +它只是信息——从来不是写入器——也不会改变 `Agent.*` 组件渲染的任何内容。 + +```ts +interface AgentTerminal { + hostSurface: 'cli' | 'mcp' | 'hook' | 'script' | 'workbench'; + stdout: { kind: 'tty' | 'pipe' | 'none'; color: 'none' | 'basic' | '256' | 'truecolor'; columns?: number; rows?: number }; + stderr: { kind: 'tty' | 'pipe' | 'none'; color: 'none' | 'basic' | '256' | 'truecolor'; columns?: number; rows?: number }; + sharesTarget: boolean; // fd 1 与 fd 2 指向同一个打开的文件(`2>&1`,或同一个终端) +} +``` + +颜色遵循非正式标准的惯常优先级:设置了 `FORCE_COLOR` 时由它直接决定(`0`/`false` 关闭;空值、`1` +或 `true` 为 basic;`2` 为 256;`3` 为 truecolor),`CLICOLOR_FORCE` 即使对管道也强制开启颜色, +`NO_COLOR`(任何非空值)与 `CLICOLOR=0` 强制关闭,`TERM=dumb` 不渲染颜色,其余情况下终端按 +`COLORTERM`/`TERM` 声明的深度渲染,管道则不渲染。`COLUMNS`/`LINES` 会覆盖上报的尺寸。 + +| 路由运行的位置 | `hostSurface` | `stdout` / `stderr` | +| --- | --- | --- | +| 路由式 CLI 可执行文件——普通、渲染式或投影的 MCP 命令 | `cli` | 从可执行文件自身进程探测。机器输出独占 stdout,因此 `stdout` 说明文档落在哪里,`stderr` 则是路由可以自行写入的通道。 | +| 渲染式脚本(`src/scripts/.tsx`) | `script` | 同上,探测得出。 | +| 生成的 MCP 服务器 | `mcp` | 两者均为 `none`——stdout 是协议线路,stderr 是宿主的日志。从不探测,也从不猜测。 | +| 事件路由(共享运行时或独立钩子进程) | `hook` | 两者均为 `none`——stdout 是宿主的钩子信封。 | +| Workbench 生命周期回放 | `workbench` | 两者均为 `none`。 | +| `defineRscApplication` 适配器 | `mcp` / `cli` | `createRscMcpServer` 两者均挂载 `none`;`runRscCli` 自身不做探测,只挂载调用方在选项中传入的 `terminal`,否则为 `unavailable`(`not-provided`)。 | +| 未挂载该轴的自定义宿主 | — | `unavailable`(`not-provided`)。 | + +导出 `main` 的普通脚本或 bin 没有请求作用域;其封套会把同一个值作为 `main` 的第二个参数传入(见 +[包入口](./package-entries.mdx#可执行封套))。在测试中,`invokeCli` 与 `runScript` 的 `tty` 开关会塑造 +一个确定性的合成值,而 `context.terminal` 则可以像注入任何身份轴一样注入其他取值。 + ## 流式输出与进度 路由通过渲染 React `Suspense` 实现流式输出:外壳(shell)先带着回退内容发出,之后每个解析完成的 diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 80abc550f..083ac7ddf 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -54,14 +54,21 @@ export default defineConfig({ ```ts // src/cli.ts — the whole CLI entry a consumer writes -export const main = async (argv: readonly string[]): Promise => { - // ... +import type { ExecutableMainContext } from 'agent-bundle'; + +export const main = async (argv: readonly string[], { terminal }: ExecutableMainContext): Promise => { + if (terminal.stderr.color !== 'none') { /* 在 stderr 上绘制进度 */ } return 0; }; ``` -封套会 await `main(process.argv.slice(2))`,把数值返回值作为进程退出码,并让逃逸的 rejection 走 -Node 的顶层失败路径(堆栈打到 stderr,退出码 1)。没有 `main` 导出的自执行模块则逐字节直接打包。 +封套会 await `main(process.argv.slice(2), { terminal })`,把数值返回值作为进程退出码,并让逃逸的 +rejection 走 Node 的顶层失败路径(堆栈打到 stderr,退出码 1)。`terminal` 是该进程的 +[终端能力](./mcp.mdx#终端能力):stdout 与 stderr 各自的 TTY 属性、颜色与尺寸,以及两者是否指向同一 +目标,由封套内联的一个无依赖模块在 `main` 运行前探测一次——普通脚本或 bin 不会为此加载任何运行时。 +包 bin 的 `hostSurface` 为 `cli`,产物脚本的为 `script`,因此同时发布到两个表面的模块看到的是它实际 +被启动的那个表面。只声明一个参数的 `main` 照常工作,第二个参数只是没有被读取。没有 `main` 导出的 +自执行模块则逐字节直接打包,也不会收到探测结果。 ## 路由式 CLI diff --git a/website/docs/zh/guide/authoring/scripts-assets.mdx b/website/docs/zh/guide/authoring/scripts-assets.mdx index 66d4d63c1..f59650a9d 100644 --- a/website/docs/zh/guide/authoring/scripts-assets.mdx +++ b/website/docs/zh/guide/authoring/scripts-assets.mdx @@ -71,6 +71,11 @@ react-server worker。 `--ndjson` 是 agent-bundle 的 CLI 与脚本输出方言,不是 MCP JSON-RPC,并且绝不会作为非 MCP 字节写入 某个 MCP 服务器的 stdout。诊断信息留在 stderr;机器可读输出独占 stdout。 +输出模式由可执行文件探测到的[终端能力](./mcp.mdx#终端能力)决定,而组件通过 `(await agent()).terminal` +(`hostSurface: 'script'`)读到的正是同一个值——因此一个自行向 stderr 写进度的脚本,其上色与定宽会与 +框架完全一致,无需自己探测 `process.stdout`。导出 `main` 的普通脚本则把同一个值作为 `main` 的第二个 +参数收到(见[包入口](./package-entries.mdx#可执行封套))。 + ### 运行脚本 `script.run` 是生产环境挂载、可信本地的 Workbench Playground 操作。它只在受管工作区中、为所选 target