Skip to content
6 changes: 6 additions & 0 deletions .changeset/511-terminal-capability.md
Original file line number Diff line number Diff line change
@@ -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<AgentTerminal>` 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)
10 changes: 10 additions & 0 deletions docs/effect-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
90 changes: 83 additions & 7 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentTerminal>` (#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/<name>.js`, `<target>/bin/<name>.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/<name>.mjs` from `src/scripts/<name>.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
Expand Down Expand Up @@ -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<number> => {
// ...
import type { ExecutableMainContext } from 'agent-bundle';

export const main = async (argv: readonly string[], { terminal }: ExecutableMainContext): Promise<number> => {
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/<name>.js`) and
`script` for an artifact script (`scripts/<name>.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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Agent.Result>
<Agent.Markdown>{`Observed ${canonical.event} from ${canonical.provenance.host}.`}</Agent.Markdown>
<Agent.Context>{actorContext}</Agent.Context>
<Agent.Context>{terminalContext}</Agent.Context>
{notices.map((notice) => (
<Agent.Context key={notice.id}>{`notice ${notice.id}: ${notice.message}`}</Agent.Context>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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<number | undefined> => {
export const main = async (argv: readonly string[], context: ExecutableMainContext): Promise<number | undefined> => {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<surface>/<stdout kind>/<stderr kind>`.
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 (
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bundle/src/adapters/hook-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,7 @@ const eventRouteHookWrapperSource = (
' lineage: context.lineage,',
' requestInvocation: context.invocation,',
' session: context.session,',
' terminal: context.terminal,',
' type: "render",',
' workspace: context.workspace,',
' });',
Expand Down Expand Up @@ -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);',
Expand Down
45 changes: 28 additions & 17 deletions packages/agent-bundle/src/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
generatedExecutableEntrySource,
generatedRenderedRouteWorkerSource,
generatedRenderedScriptEntrySource,
terminalCapabilityRuntimePath,
terminalCapabilityRuntimeSpecifier,
generatedRouteArtifactEpoch,
generatedRouteFlightWorkerSource,
generatedRouteMcpEntrySource,
Expand All @@ -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 [
Expand Down Expand Up @@ -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<RslibEntry>[] => {
const { name, rendered, source, sourceInputs } = entry;
if (rendered !== undefined) {
const workerSourceInputs = Object.freeze([...new Set([
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading