Skip to content

Commit 4b494b8

Browse files
Merge branch 'main' into feat/460-published-notices
2 parents 36367d8 + 178237c commit 4b494b8

64 files changed

Lines changed: 3209 additions & 132 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'agent-bundle': patch
3+
'@agent-bundle/runtime': patch
4+
---
5+
6+
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)

‎.changeset/514-serve-app.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": patch
3+
---
4+
5+
Add `agent-bundle serve-app <server>/<app>` and `serveApp` in `agent-bundle/api`: serve one built MCP App standalone in a browser, bound to the plugin's own packed MCP server. The server launches exactly as `mcp run` does (same artifact resolution, `.env` layering, and plugin-data root), the App is hosted through the Workbench's MCP App host stack (sandbox proxy, consent authority, bridge) on `127.0.0.1` behind a per-launch token (`AB8003` / `AB8004` on refusal), and the App's tool is called once so it opens populated. `--tool`, `--input`, `--port`, `--profile`, `--allow <capability>`, `--open`, and the `mcp run` environment flags select the binding; `serveApp` returns `{ url, close, closed }` so a plugin's own CLI route can offer an "open the dashboard" command. Fixes #514. (#537)

‎docs/effect-conventions.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,16 @@ Wiring rules:
563563
`install/surface.ts`), and child/worker stderr forwarding keep their direct
564564
`process.stdout`/`process.stderr` adapters: emitted artifacts must not carry
565565
a platform runtime, and byte-exact protocol frames are not terminal text.
566+
- **The route-facing terminal capability is plain Node, not `Terminal`.**
567+
`request.terminal` (#511) — TTY-ness, color depth, and `columns`/`rows` per
568+
output stream, reported to routes, rendered scripts, and `main`-envelope
569+
executables — is probed by the dependency-free `src/terminal-capability.ts`
570+
(aliased into emitted executables as `agent-bundle/terminal-capability`)
571+
because those artifacts must not carry the Effect runtime; the first-party
572+
CLI mounts no route request scope, so it has nothing to read from the
573+
`Terminal` service for it. `Terminal.columns`/`rows` remain the first-party
574+
CLI's own way to size its human output. Rules and the per-surface table:
575+
[Terminal capability](entry-conventions.md#terminal-capability-requestterminal).
566576

567577
## Effect Schema wire contracts (Schema projections)
568578

‎docs/entry-conventions.md‎

Lines changed: 118 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,72 @@ the in-memory MCP proof level accepts a registry
594594
(`openInMemoryMcpServer({ lineage, lineageHost })`) so hook→MCP correlation
595595
is testable without a spawned process.
596596

597+
#### Terminal capability (`request.terminal`)
598+
599+
`(await agent()).terminal` is an `Observed<AgentTerminal>` (#511): what the
600+
process's output streams are, computed **once per invocation by the framework
601+
shell** with the same rules that pick the CLI output mode, so a route that
602+
colors its own stderr or sizes its own table agrees with the framework's
603+
rendering instead of re-probing `process.stdout` per plugin. It is information
604+
only — never a writer — and it never changes what `Agent.*` components render.
605+
606+
```ts
607+
interface AgentTerminal {
608+
hostSurface: 'cli' | 'mcp' | 'hook' | 'script' | 'workbench';
609+
stdout: AgentTerminalStream;
610+
stderr: AgentTerminalStream;
611+
sharesTarget: boolean; // fd 1 and fd 2 name one open file (`2>&1`, one shared terminal)
612+
}
613+
interface AgentTerminalStream {
614+
kind: 'tty' | 'pipe' | 'none'; // interactive terminal | any other open descriptor | no stream for the route
615+
color: 'none' | 'basic' | '256' | 'truecolor';
616+
columns?: number; // present for a terminal, or when COLUMNS overrides
617+
rows?: number; // present for a terminal, or when LINES overrides
618+
}
619+
```
620+
621+
The probe (`src/terminal-capability.ts`, plain Node, dependency-free, aliased
622+
into emitted executables as `agent-bundle/terminal-capability`) reads
623+
`isTTY`, `columns`, and `rows` off `process.stdout`/`process.stderr`, `fstat`s
624+
the descriptors (`tty`; any other open descriptor is `pipe`; a closed one is
625+
`none`; `sharesTarget` compares device and inode), and resolves color in the
626+
informal standards' precedence: `FORCE_COLOR` decides outright when set
627+
(`0`/`false` off; empty, `1`, or `true` basic; `2` 256; `3` truecolor — Node's
628+
reading), then `CLICOLOR_FORCE` forces color on even for a pipe at the depth
629+
`COLORTERM`/`TERM` advertise, `NO_COLOR` (any non-empty value) and `CLICOLOR=0`
630+
force it off, `TERM=dumb` renders none, and otherwise a terminal renders at its
631+
advertised depth while a pipe renders none. `COLUMNS`/`LINES` override the
632+
reported size whatever the stream is. The routed CLI derives its `tty` versus
633+
piped-Markdown mode from this same value (`stdout.kind === 'tty'`), so the two
634+
can never disagree.
635+
636+
Per surface, the value the generated request scope mounts:
637+
638+
| Surface | `hostSurface` | `stdout` / `stderr` | Source |
639+
| --- | --- | --- | --- |
640+
| 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` |
641+
| Rendered script (`scripts/<name>.mjs` from `src/scripts/<name>.tsx`) | `script` | Probed, as above. | `native` |
642+
| 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` |
643+
| Event route (shared runtime or standalone hook process) | `hook` | `none` on both — stdout is the host's hook envelope. Never probed. | `derived` |
644+
| Workbench lifecycle replay | `workbench` | `none` on both — the document renders into a panel. | `derived` |
645+
| `createRscMcpServer` (the `defineRscApplication` MCP adapter) | `mcp` | `none` on both, as above. | `derived` |
646+
| `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` / — |
647+
| Custom host calling `runAgentRequest` without `terminal` | — | `unavailable` (`not-provided`) | — |
648+
649+
Plain `main`-exporting scripts and bins have no request scope, so the
650+
executable envelope hands them the same probe directly as the second argument
651+
of `main` (see [The executable envelope](#the-executable-envelope-bin--scripts)).
652+
653+
The `agent-bundle/test` harness never probes the test runner's own streams:
654+
`invokeCli` and `runScript` mount a deterministic synthetic value shaped by
655+
their `tty` knob (an 80×24 `basic`-color terminal on both streams with
656+
`sharesTarget: true`, or two `color: 'none'` pipes), `renderRoute` mounts what
657+
the artifact's scope for that route kind would (`none` for MCP and event
658+
routes, the piped shape for `cli` and `script` kinds), the in-memory MCP level
659+
forwards the real server's `none`, and a plain script's `main` receives the
660+
real child process's probe (two pipes). A test that wants other values injects
661+
`context.terminal` through the same seam as every identity axis.
662+
597663
### Migration nudges
598664

599665
Source validation reports **informational** nudges (never errors — migrations
@@ -625,17 +691,27 @@ default function for bin entries) receives the generated process envelope:
625691

626692
```ts
627693
// src/cli.ts — the whole CLI entry a consumer writes
628-
export const main = async (argv: readonly string[]): Promise<number> => {
629-
// ...
694+
import type { ExecutableMainContext } from 'agent-bundle';
695+
696+
export const main = async (argv: readonly string[], { terminal }: ExecutableMainContext): Promise<number> => {
697+
if (terminal.stderr.color !== 'none') { /* paint progress on stderr */ }
630698
return 0;
631699
};
632700
```
633701

634-
The envelope awaits `main(process.argv.slice(2))`, adopts a numeric return as
635-
the process exit code, and lets an escaped rejection surface through Node's
636-
top-level failure path (stack to stderr, exit code 1). Self-executing modules
637-
(no `main` export) bundle directly, byte for byte — existing Scripts keep
638-
their behavior.
702+
The envelope awaits `main(process.argv.slice(2), { terminal })`, adopts a
703+
numeric return as the process exit code, and lets an escaped rejection surface
704+
through Node's top-level failure path (stack to stderr, exit code 1).
705+
`terminal` is the process's [terminal capability](#terminal-capability-requestterminal)
706+
(#511), probed once before `main` runs by the dependency-free
707+
`agent-bundle/terminal-capability` module the envelope aliases in — plain
708+
scripts and bins load no Effect runtime and no `@agent-bundle/runtime` for it.
709+
Its `hostSurface` is `cli` for a package bin (`dist/bin/<name>.js`) and
710+
`script` for an artifact script (`scripts/<name>.mjs`); a module shipped on
711+
both surfaces sees the surface it was launched from. A `main` declared with
712+
one parameter keeps working — the second argument is simply unread.
713+
Self-executing modules (no `main` export) bundle directly, byte for byte —
714+
existing Scripts keep their behavior and receive no probe.
639715

640716
### The routed CLI shell (#102 stages 2-3)
641717

@@ -1182,3 +1258,38 @@ content-hashed bundle inside the target root). `--plugin-root <path>`
11821258
overrides the env-anchor root, e.g. point it at `artifact/<target>` for a
11831259
byte-faithful rehearsal of a copied-artifact launch; under a host install the
11841260
anchor still means the durable install root, exactly as before.
1261+
1262+
## `agent-bundle serve-app`
1263+
1264+
```sh
1265+
agent-bundle serve-app <server>/<app> [--artifact <path>] [--target <target>]
1266+
[--tool <name>] [--input <json> | --input-file <path>] [--port <port>]
1267+
[--profile <profile>] [--allow <capability>]... [--open]
1268+
[--env-file <path>]... [--no-env] [--plugin-root <path>]
1269+
```
1270+
1271+
Serves one built MCP App standalone in a browser, outside any MCP host and
1272+
without the Workbench. The command launches the App's packed MCP server
1273+
through exactly the `mcp run` launcher above (same manifest resolution, same
1274+
three-layer environment, same durable-state anchors), binds the App to that
1275+
one session through the Workbench's own MCP App host stack
1276+
(`McpAppBindingService` → `McpAppPreviewService` → `McpAppRoutes`, the
1277+
loopback sandbox proxy, the consent authority, `McpAppBridge`), calls the
1278+
App's tool once so it opens populated, and prints the loopback URL. It runs
1279+
in the foreground until SIGINT/SIGTERM, or until the server exits on its own,
1280+
which is reported as one `AB5000` diagnostic with exit code 1. Without
1281+
`--artifact`, a throwaway artifact is built into a staging directory beside
1282+
the project root and removed when the host closes.
1283+
1284+
The host document is served on `127.0.0.1` only, at `/`, with the
1285+
authenticated `/api/mcp/...` routes behind a per-launch token plus
1286+
same-origin and loopback `Host` checks (`AB8003` / `AB8004` on refusal); the
1287+
App document runs on a second loopback origin inside the framework sandbox,
1288+
and the bridge exposes only the selected server. This is a local preview
1289+
host, not a deployment target.
1290+
1291+
`serveApp` in `agent-bundle/api` is the programmatic form (`{ url, close,
1292+
closed }`) for a plugin's own routed CLI (`hauler dashboard`). It belongs to
1293+
the plugin's dev-time / CLI process — import it lazily from the route that
1294+
needs it — never to the MCP server shell, so emitted artifacts stay free of
1295+
the host runtime.

‎docs/framework-mode.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ as well, and is the form to use when the component also needs the URI at run
9393
time. The full grammar and the `config.template` resolution rule are in
9494
[Diagnostics](diagnostics.md).
9595

96+
A built App is previewed in the Workbench MCP page, or served standalone in a
97+
plain browser tab with `agent-bundle serve-app <server>/<app>` — the same
98+
host stack (sandbox proxy, consent authority, bridge) bound to the plugin's
99+
own packed server, launched as `mcp run` launches it. `serveApp` in
100+
`agent-bundle/api` is the programmatic form for a plugin's own "open the
101+
dashboard" CLI route; it runs in the plugin's dev-time / CLI process, never
102+
in the MCP shell, and is a local preview host, not a deployment target. See
103+
[Entry conventions](entry-conventions.md#agent-bundle-serve-app).
104+
96105
The compiler statically reads `config`, imports schemas and implementations
97106
only into generated entries, installs `runAgentRequest`, and derives the real
98107
MCP server from the route graph. Each call renders through a warm internal

‎packages/agent-bundle/fixtures/route-harness/src/events/tool/after.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ export default async function AfterTool({ canonical }: AgentEventRouteProps) {
1111
const actorContext = context.actor.state === 'available'
1212
? `actor available:${context.actor.source}:${context.actor.value.id}`
1313
: `actor unavailable:${context.actor.reason}`;
14+
// A hook has no terminal (#511); the route reports what it observed.
15+
const terminalContext = context.terminal.state === 'available'
16+
? `terminal available:${context.terminal.source} ${context.terminal.value.hostSurface}/${context.terminal.value.stdout.kind}/${context.terminal.value.stderr.kind}`
17+
: `terminal unavailable:${context.terminal.reason}`;
1418
return (
1519
<Agent.Result>
1620
<Agent.Markdown>{`Observed ${canonical.event} from ${canonical.provenance.host}.`}</Agent.Markdown>
1721
<Agent.Context>{actorContext}</Agent.Context>
22+
<Agent.Context>{terminalContext}</Agent.Context>
1823
{notices.map((notice) => (
1924
<Agent.Context key={notice.id}>{`notice ${notice.id}: ${notice.message}`}</Agent.Context>
2025
))}

‎packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/context.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const resultSchema = z.object({
1616
host: z.unknown(),
1717
lineage: z.unknown(),
1818
session: z.unknown(),
19+
terminal: z.unknown(),
1920
workspace: z.unknown(),
2021
}).strict();
2122

@@ -36,11 +37,15 @@ export default async function Context() {
3637
const lineage: JsonValue = context.lineage.state === 'available'
3738
? { source: context.lineage.source, state: context.lineage.state, value: JSON.parse(JSON.stringify(context.lineage.value)) as JsonValue }
3839
: { reason: context.lineage.reason, state: context.lineage.state };
40+
const terminal: JsonValue = context.terminal.state === 'available'
41+
? { source: context.terminal.source, state: context.terminal.state, value: JSON.parse(JSON.stringify(context.terminal.value)) as JsonValue }
42+
: { reason: context.terminal.reason, state: context.terminal.state };
3943
const result = {
4044
actor,
4145
host,
4246
lineage,
4347
session,
48+
terminal,
4449
workspace,
4550
};
4651
return (

‎packages/agent-bundle/fixtures/route-harness/src/scripts/checksum.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
1+
import type { ExecutableMainContext } from 'agent-bundle';
2+
13
/**
24
* A plain script with the `main` process-envelope contract: the generated
3-
* `scripts/checksum.mjs` awaits `main(process.argv.slice(2))` and adopts a
4-
* numeric return as the exit code. No renderer, no request context.
5+
* `scripts/checksum.mjs` awaits `main(process.argv.slice(2), { terminal })`
6+
* and adopts a numeric return as the exit code. No renderer, no request
7+
* context; the terminal capability (#511) arrives through the envelope.
58
*/
69

710
/** Module state: a fresh process starts at zero, a cached module would not. */
811
let calls = 0;
912

10-
export const main = async (argv: readonly string[]): Promise<number | undefined> => {
13+
export const main = async (argv: readonly string[], context: ExecutableMainContext): Promise<number | undefined> => {
1114
calls += 1;
1215
if (argv.includes('--explode')) {
1316
throw new Error('checksum exploded');
1417
}
18+
if (argv.includes('--terminal')) {
19+
// What the envelope probed for this process, as one canonical JSON line.
20+
process.stdout.write(`${JSON.stringify(context.terminal)}\n`);
21+
return 0;
22+
}
1523
if (argv.includes('--calls')) {
1624
process.stdout.write(`checksum call ${String(calls)} in ${process.argv[1]!}\n`);
1725
return 0;

‎packages/agent-bundle/fixtures/route-harness/src/scripts/summary.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ export default async function Summary({ argv, signal }: ScriptRouteProps) {
5757
invocation: context.invocation.kind,
5858
stateMounted: context.state !== undefined,
5959
surface: context.invocation.surface ?? null,
60+
// The executable's probed terminal (#511), as `<surface>/<stdout kind>/<stderr kind>`.
61+
terminal: context.terminal.state === 'available'
62+
? `${context.terminal.value.hostSurface}/${context.terminal.value.stdout.kind}/${context.terminal.value.stderr.kind}`
63+
: `unavailable:${context.terminal.reason}`,
6064
};
6165
if (argv.includes('--fail')) {
6266
return (

‎packages/agent-bundle/rslib.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export default defineConfig({
112112
// into its generated bundle.
113113
routes: './src/routes/public.ts',
114114
rstest: './src/rstest/index.ts',
115+
'terminal-capability': './src/terminal-capability.ts',
115116
test: './src/test/index.ts',
116117
'test/browser': './src/test/browser.ts',
117118
},

0 commit comments

Comments
 (0)