diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml
index 9f56a1e9e..f617f52d1 100644
--- a/.github/workflows/publish-packages.yml
+++ b/.github/workflows/publish-packages.yml
@@ -30,7 +30,7 @@ jobs:
- name: Validate the manifests declare this version
run: |
VERSION="${{ steps.resolve.outputs.value }}"
- for f in packages/durable-streams/deno.json packages/runtime/deno.json packages/core/deno.json packages/acp/deno.json packages/testing/deno.json packages/test-agent/deno.json packages/web/deno.json packages/workflow/deno.json packages/cli/deno.json packages/code-review-agent/deno.json; do
+ for f in packages/durable-streams/deno.json packages/grid/deno.json packages/runtime/deno.json packages/core/deno.json packages/acp/deno.json packages/grid-tmux/deno.json packages/testing/deno.json packages/test-agent/deno.json packages/web/deno.json packages/workflow/deno.json packages/cli/deno.json packages/code-review-agent/deno.json; do
declared="$(jq -r .version "$f")"
if [ "$declared" != "$VERSION" ]; then
echo "::error::$f declares $declared, not $VERSION — the tag does not match the manifests"
@@ -68,6 +68,13 @@ jobs:
package: packages/durable-streams
version: ${{ needs.version.outputs.value }}
+ grid:
+ needs: [version, durable-streams]
+ uses: ./.github/workflows/publish-one.yml
+ with:
+ package: packages/grid
+ version: ${{ needs.version.outputs.value }}
+
runtime:
needs: [version]
uses: ./.github/workflows/publish-one.yml
@@ -76,19 +83,26 @@ jobs:
version: ${{ needs.version.outputs.value }}
core:
- needs: [version, durable-streams, runtime]
+ needs: [version, durable-streams, grid, runtime]
uses: ./.github/workflows/publish-one.yml
with:
package: packages/core
version: ${{ needs.version.outputs.value }}
acp:
- needs: [version, core, runtime]
+ needs: [version, core, grid, runtime]
uses: ./.github/workflows/publish-one.yml
with:
package: packages/acp
version: ${{ needs.version.outputs.value }}
+ grid-tmux:
+ needs: [version, grid]
+ uses: ./.github/workflows/publish-one.yml
+ with:
+ package: packages/grid-tmux
+ version: ${{ needs.version.outputs.value }}
+
testing:
needs: [version, core, durable-streams, runtime]
uses: ./.github/workflows/publish-one.yml
@@ -97,7 +111,7 @@ jobs:
version: ${{ needs.version.outputs.value }}
test-agent:
- needs: [version, acp, core, durable-streams, runtime, testing]
+ needs: [version, acp, core, durable-streams, grid, runtime, testing]
uses: ./.github/workflows/publish-one.yml
with:
package: packages/test-agent
@@ -118,7 +132,7 @@ jobs:
version: ${{ needs.version.outputs.value }}
cli:
- needs: [version, acp, core, durable-streams, runtime, test-agent, testing, web, workflow]
+ needs: [version, acp, core, durable-streams, grid, grid-tmux, runtime, test-agent, testing, web, workflow]
uses: ./.github/workflows/publish-one.yml
with:
package: packages/cli
diff --git a/architecture.md b/architecture.md
index a6b73181b..c1cf8aa33 100644
--- a/architecture.md
+++ b/architecture.md
@@ -108,10 +108,10 @@ Existing documents and code get aligned to this section retroactively.
| session materialization | the transition that makes a placement's chosen route and its backend history resumable. ACP-first materialization happens only when the backend reports that it accepted the session's first turn; client-native materialization is the native launch's existing retained construction. Nothing else promotes a placement — not a returning ensure, a first output, a terminal result, a checkpoint token, an error code or a diagnostic |
| established session | a placement whose immutable construction route and durable provider or native identity both already exist, and which is therefore validated eagerly: reattached, compared against its retained history, and refused when either is missing or names another conversation |
| instruction layer | the provider-native session, system or developer instructions a launch installs before the native UI accepts its first user turn. It is not a user message, and it is not conversation history |
-| foreground-terminal lease | the one exclusive claim on a document execution's foreground experience. A root native launch holds it for one inherited terminal; a terminal grid holds it for one composite presentation. A host with no terminal refuses it, and no second root launch or grid can hold it concurrently |
-| terminal grid | one provider-neutral foreground region whose direct terminal panes begin concurrently, remain independently interactive, and settle under one scope after complete provider and pane teardown |
-| terminal pane | one authored position in a terminal grid, identified structurally by its grid and ordinal and presented by its authored title. It owns one interactive terminal at a time; a paired pane expands its own document flow and a self-closing pane runs the host's default shell |
-| pane-terminal lease | the exclusive claim one live interactive operation holds on one terminal pane. Claims in different panes do not contend; two claims in one pane do. It is minted and validated by the host's terminal authority and grants no authority over an Agent session |
+| foreground-terminal lease | the one exclusive claim on a document execution's foreground experience. A root native launch holds it for one inherited terminal; a grid holds it for one composite presentation. A host with no terminal refuses it, and no second root launch or grid can hold it concurrently |
+| grid | one provider-neutral foreground presentation whose direct panes begin concurrently, remain independently usable, and settle under one scope after complete provider and pane teardown |
+| pane | one authored position in a grid, identified structurally by its grid and ordinal and presented by its authored title. A paired pane expands its own document flow and a self-closing pane runs the host's default shell; a pane acquires an interactive terminal only when its content requires one |
+| pane-terminal lease | the exclusive claim one live interactive operation holds on one pane's terminal capability. Claims in different panes do not contend; two claims in one pane do. It is minted and validated by the host's terminal authority and grants no authority over an Agent session |
| native launcher | the host-owned seam that reserves the foreground terminal or the current pane terminal, flushes what that terminal has pending, starts one native UI there, and reports its terminal status and nothing else. It is not `exec`, whose children are piped, captured and journaled |
| launch request | the frozen, one-use value public launch middleware routes. It carries the facts of one launch and `with()`, and nothing that can settle one. Identity is object identity: a rebuilt look-alike describes the same ask and authorizes none of it |
| provider authority | what core delivers to the provider factory it installs, as an argument that factory closes over. It validates the routed request, runs each absent phase once, cross-checks and retains what comes back, and derives the result. There is no reader for one, no context holding one, and no request member carrying one |
@@ -3406,47 +3406,46 @@ inside the run's existing deadline rather than opening a lifecycle of its own,
and a waiting read is cancellable: cancellation tears the reader down and stays
cancellation, never a read failure.
-## Interactive terminal grids
+## Interactive grids
-An executable document can replace its one foreground terminal with one
-provider-neutral grid of independently interactive terminal panes:
+An executable document can replace its one foreground presentation with one
+provider-neutral grid of independently usable panes:
```md
-
-
+
+
Implement the accepted plan.
-
-
+
+
Review the implementation against the plan.
-
-
-
+
+
+
```
-`Terminal` names the interactive endpoint the document requires. It does not
-name the presentation technology: a tmux integration, another terminal
-multiplexer, and a host-native composite UI are providers for the same
-contract. A component that elicits values through a terminal UI is a different
-abstraction, just as `` is one presentation for ``; it does not
-change what an interactive process requires here.
+`Grid` and `Pane` name the presentation structure the document requires. They
+do not name its technology: a tmux integration, another multiplexer, and a
+host-native composite UI are providers for the same contract. Terminal is a
+capability a pane acquires when an interactive process or shell requires a PTY;
+it is not the identity of the grid or every cell.
-The grid and its panes are core-owned structural syntax. `` is
-paired, requires a positive integer `columns`, and contains at least one direct
-`` child. Whitespace may separate those children, but ordinary text,
+The grid and its panes are core-owned structural syntax. `` is paired,
+requires a positive integer `columns`, and contains at least one direct
+`` child. Whitespace may separate those children, but ordinary text,
dynamic control structures, and every other direct element are invalid. A
pane requires a non-empty `title`; titles are display labels and need not be
unique. Its ordinal among the direct children is its structural identity.
Rows are derived in row-major order from the pane count and columns. A paired
pane expands ordinary document flow; a self-closing pane runs the host's
-default shell. A nested grid and a `` outside a grid are invalid.
+default shell. A nested grid and a `` outside a grid are invalid.
Neither form accepts a provider, executable, shell, layout identifier, or
`as`, and neither renders or returns document content.
@@ -3464,6 +3463,124 @@ outside that pane. Each pane also owns its checked-failure ledger. A checked
failure settles that pane without poisoning the root or a sibling; core alone
observes the pane outcome and applies the grid's settlement rule after close.
+### Package ownership
+
+The grid domain is independent of both the document engine that invokes it and
+the presentation provider that implements it. Two publishable workspace
+packages make those boundaries explicit:
+
+- `@executablemd/grid` owns native foreground-launch routing and terminal
+ reservation; provider-neutral grid and pane requests, composites, states,
+ errors, and row-major layout; `Grids` and `GridProviders` routing; provider
+ registration and direct authority delivery; claims, readiness,
+ stale-authority refusal, lifecycle, reader-close settlement, retained
+ outcomes, replay, pane-scoped launch routing, terminal process observation,
+ quiescence, and controlled test surfaces.
+- `@executablemd/grid-tmux` owns tmux capability probing and commands, the
+ hidden server and control clients, explicit layout and pane swaps, visible
+ attach, authenticated Unix-socket channels and their protocol, the persistent
+ pane worker and its child, worker invocation, and the provider's one ordered
+ teardown. No tmux command, type, identifier, protocol value, or host probe is
+ part of the neutral package.
+
+`@executablemd/grid` exports its ordinary domain surface from the package root.
+Its `./lifecycle` entrypoint exports authority creation, provider installation,
+claims, readiness, grid execution, retained outcomes, and reader close. Its
+`./processes` entrypoint exports `TerminalProcesses`, process facts and signals,
+snapshots, and quiescence. Its `./posix` entrypoint exports the POSIX process and
+terminal probes and foreground-child adapter. Its `./test` entrypoint exports
+only controlled launchers, composites, logs, and signals. Anything exported
+from more than one facet is the same object.
+
+`@executablemd/grid-tmux` exports `TMUX_PROVIDER`,
+`TmuxProviderDependencies`, `tmuxGridProvider`, `installTmuxGridProvider`, the
+unchanged `PANE_WORKER_COMMAND`, the hidden pane-worker invocation parser, the
+pane-worker process runner, and the provider's documented refusal errors from
+its root. Protocol frames, channel handles, tmux process wrappers, layout
+mechanics, and teardown hooks stay private. Controlled low-level seams exist
+only at its `./test` entrypoint.
+
+The dependency graph points toward the neutral domain:
+
+```text
+@executablemd/grid-tmux ──> @executablemd/grid
+@executablemd/core ──> @executablemd/grid
+@executablemd/cli ──> core + runtime + grid + grid-tmux
+```
+
+The grid package may depend on durable streams, Effection, and EffectionX; it
+never imports runtime, core, CLI, or grid-tmux. Grid-tmux never imports runtime,
+core, or CLI. Runtime has no grid dependency. Core remains the owner of Markdown
+parsing and expansion, `SourcePosition` journal descriptions,
+execution-profile installation, and Agent session behavior. Its grid journal
+and profile adapters stay in core. CLI retains only entrypoint and execution
+composition.
+
+The unmerged `packages/terminal` and `packages/terminal-tmux` trees become
+`packages/grid` and `packages/grid-tmux`. Their manifests, exports, tests,
+workspace declarations, generated publication entries, and consumers move with
+them. The former runtime and core terminal exports, old CLI terminal
+implementation paths, rejected package names, and old authored syntax are
+deleted. No compatibility component, package, module, alias, forwarding barrel,
+wrapper, subclass, or duplicate descriptor remains. Every repository import is
+canonical, and each contextual API and public error constructor has one
+definition and import path.
+
+POSIX process-table, process-group, signal, reachability, and terminal-holder
+observation lives behind `@executablemd/grid/posix`, not in the tmux adapter. A
+different POSIX presentation provider can reuse the same proof without tmux.
+The Deno and compiled CLI entrypoints choose tmux, resolve self-reinvocation,
+terminal size and environment, translate host `SIGHUP` into structured
+cancellation, and install the POSIX observer in both the supervising run and
+each pane worker. Node and Bun install neither observer nor grid provider.
+
+This boundary change preserves the provider name `tmux`, hidden worker verb
+`terminal-worker`, worker protocol and authentication, durable behavior and
+identities, readiness, close and replay semantics, terminal capability, and
+every provider identity. It deliberately changes the authored names, canonical
+package and import paths, public grid descriptors and errors, and diagnostics
+that identify those authored constructs. Event registrations remain owned by
+the Effection scope whose resource they observe and are removed when that scope
+settles.
+
+Both packages are ordinary lockstep-versioned workspace members. The generated
+publication graph places grid after durable-streams, grid-tmux and core after
+grid, and CLI after grid-tmux, grid, core, and runtime. Workspace, JSR, npm,
+compiled-host, and runtime-test discovery treat them like every other
+publishable package.
+
+The final boundary is established by finite evidence:
+
+1. A static dependency test proves the graph above and the absence of
+ grid-to-runtime/core/CLI/tmux and grid-tmux-to-runtime/core/CLI edges.
+2. A package-boundary test proves the rejected component names, packages,
+ runtime and core exports, and CLI implementation paths absent; all grid
+ imports are canonical; and every public contextual descriptor and error
+ constructor has one definition.
+3. Relocated neutral tests retain foreground launch, routing, authority,
+ readiness, layout, close, cancellation, replay, process observation, and
+ quiescence without tmux.
+4. Core tests retain grammar, structural validation, source diagnostics, pane
+ scope, durable identities and records, outcomes, and provider-neutral replay
+ under `` and ``.
+5. Grid-tmux tests retain exact authenticated transport, concurrent panes,
+ sequential reuse, spawn readiness, display isolation, job control, explicit
+ row-major layout, atomic attach, distinct close signals, SIGHUP, scope-owned
+ listeners, cancellation phases, and ordered bounded teardown.
+6. Cross-package Agent tests retain pane-native physical routing, root launch,
+ and natural-key Agent session ownership.
+7. CLI evidence retains Deno and compiled tmux assembly and hidden-worker POSIX
+ observation; Node, Bun, non-TTY, and missing-tmux paths install no partial
+ provider and keep their refusal boundaries.
+8. Workspace and release evidence proves both packages discovered and published
+ in dependency order, JSR and local-sibling npm consumption, compiled binary
+ and hidden worker, runtime-test discovery, and dependency-state cleanliness.
+
+Tests use controlled signals and observable settlement for lifecycle success;
+elapsed time is not evidence. Focused feedback uses the smallest explicit tests
+that discriminate the boundary, while runtime matrices, lint, typecheck, JSR,
+and clean composability remain delivery gates.
+
### Terminal authority
One grid holds the execution's foreground-terminal lease for its whole visible
@@ -3493,15 +3610,35 @@ request to act. This preserves provider composition without letting a document
or replacement context mint terminal ownership.
A pane claim grants one interactive terminal at that ordinal, not an Agent
-session. Core installs a pane-scoped native launcher that closes over the claim.
-`` in that pane consequently reserves, flushes, and launches on
-the pane terminal instead of competing for the root lease. Launches in
-different panes may run concurrently; two interactive launches in one pane
-cannot. Sequential launches in one paired pane remain ordinary composition.
-The session coordinator is unchanged and independently authoritative, so two
-panes attempting to own the same logical Agent session still contend and one
-is refused. The provider starts a self-closing pane's host-configured default
-shell under the same kind of pane claim.
+session. Core installs a pane-scoped native launcher that closes over the claim,
+the composite, and the authored ordinal. `` in that pane
+consequently reserves and flushes the pane, then terminates native-launch
+routing at this required provider-neutral composite operation:
+
+```ts
+launch(
+ ordinal: number,
+ request: NativeLaunchRequest,
+ spawned: () => void,
+): Operation;
+```
+
+The ordinal exists only in core's live closure and never enters the native or
+Agent request. Once nearer native-launch middleware delegates, the pane launcher
+calls the composite operation instead of the root foreground launcher. Nearer
+middleware may still observe, wrap, refuse, or short-circuit the request. A
+composite that cannot execute the pane request refuses explicitly; falling
+through to the root would put the child on the wrong physical terminal. Root
+`` retains its existing foreground-launch route unchanged.
+
+Launches in different panes may run concurrently; two interactive launches in
+one pane cannot. Sequential launches in one paired pane remain ordinary
+composition. The session coordinator is unchanged and independently
+authoritative, so two panes attempting to own the same logical Agent session
+still contend and one is refused. The composite's separate `shell()` operation
+starts a self-closing pane's host-configured default shell under the same kind
+of pane claim; it remains separate because its executable is live host policy,
+not an authored or Agent-provided native launch request.
Each claim also closes over one host-owned readiness latch. The pane-scoped
native launcher acknowledges it from the runtime's successful child-spawn event
@@ -3563,16 +3700,48 @@ The grid runs as one structured scope:
6. Once attached, each pane settles independently and keeps its final status
visible while siblings continue. The composite remains present after all
panes settle until the reader closes or leaves it.
-7. Closing begins an ordered teardown: prevent new pane launches, cancel live
- pane scopes, await every child and finalizer, detach and destroy the exact
- provider composite, restore the root terminal, and only then release the
- foreground lease and settle the grid. The document never continues while an
- observable pane child or provider-owned process can still act through the
- grid.
+7. Reader close first crosses a live close boundary, then begins an ordered
+ teardown: prevent new pane launches, ask live pane children to close, await
+ every child and finalizer, detach and destroy the exact provider composite,
+ restore the root terminal, and only then release the foreground lease and
+ settle the grid. The document never continues while an observable pane child
+ or provider-owned process can still act through the grid.
+
+The provider's `closed()` settlement proposes the live close boundary. The
+boundary is crossed when the grid owner has entered a cancellation-deferred
+await of the grid's durable child and acknowledges that proposal; only then may
+the child signal pane close. That await ends only when the task has settled and
+its durable `Close` has been acknowledged, not when the grid body has merely
+chosen an outcome. This handshake has no provider identity and is not itself
+journaled.
+
+Reader-close intent becomes durable only as that completed grid `Close`, after
+pane and provider teardown. There is no standalone durable "closing" state. The
+gap between observing close and committing it is safe because ordinary parent
+cancellation is held pending across the whole gap. A cancellation that arrives
+before the owner acknowledges the close boundary cancels the active grid. One that arrives
+afterward does not rewrite grid or pane outcomes: panes already settled keep
+their outcomes, each then-live pane completes its own scope and retains
+`closed`, and the grid retains the same `reader` or `failed` result it would
+have retained without the cancellation. Once the grid child is durably closed,
+the pending cancellation is delivered to the parent, so no following document
+sibling runs in that attempt. A fatal or cleanup failure still takes its
+existing precedence over cancellation.
+
+Pane work and every finalizer it installs live inside that pane's durable child
+scope. Reader close is cooperative at the durable boundary: it asks the pane to
+close and awaits it; it never halts the pane's durable task. The pane may stop
+its live nested work as part of its own scope teardown, but its durable child
+does not settle as `closed` or write `Close(ok)` until that work and its finalizers
+have settled. This preserves the pane's ordinal-derived identity and never
+turns a deliberate reader close into a caller-cancelled durable child that a
+later run could revive or wait on forever.
Parent cancellation follows the same teardown from preparation, readiness, or
-the active grid and remains cancellation. A provider or host failure cancels
-the whole grid and is the grid's canonical failure. An ordinary pane failure
+the active grid and remains cancellation. Once reader close has crossed its
+live boundary, the close result is committed first and that cancellation is
+observed by the parent afterward. A provider or host failure cancels the whole
+grid and is the grid's canonical failure. An ordinary pane failure
after attachment is contained as that pane's status and does not cancel its
siblings. When the reader closes the grid, core fails it with the first failed
pane in authored order; cancellation initiated by grid teardown is not a pane
@@ -3608,7 +3777,7 @@ observable ownership mechanism of its own instead of severing all three links.
### Durability and replay
-A terminal grid is a core-owned structured durable region. Its layout identity
+A grid is a core-owned structured durable region. Its layout identity
contains the columns and the ordered pane forms and titles, never a provider or
live terminal identifier. Each pane is a deterministic durable child coroutine,
so effects in paired content retain and replay under the same rules they use
@@ -3623,8 +3792,41 @@ a terminal provider, starting a shell, expanding pane content, acquiring an
Agent session, or launching a native UI. The structured durable boundary owns
that short circuit; a public replay context does not.
-Partial replay first compares the exact authored layout and refuses divergence
-before provider work. It rebuilds a fresh provider composite: completed pane
+The reader-close handshake makes cancellation during teardown a completed-grid
+case rather than a new partial-replay state. When a pane finalizer delays close
+and parent cancellation arrives, the first attempt still finishes every pane
+and provider finalizer, writes the pane outcomes and completed grid `Close`, and
+only then reports cancellation to its parent. A continuation claims that
+completed child and resumes after it without recreating the provider or
+re-entering pane work. A host loss can still interrupt the unjournaled live
+teardown; panes whose `Close` was acknowledged remain complete, while any pane
+and grid without a completed record follow the existing partial-replay rules.
+
+Partial replay compares the **resolved** layout and refuses divergence before
+provider work.
+
+What that can and cannot cover follows from where a resumed run gets its
+document. A continuation executes the root the journal retained: the source the
+new invocation supplies is not read, not compared and not refused. So the
+authored structure of a grid — how many panes it has, their order, and whether
+each was written paired or self-closing — is fixed for the whole life of a
+journal, and cannot differ between runs. Comparing it would compare a value with
+itself.
+
+What can still differ is everything the retained source *resolves*: `columns`
+and each `title` are expressions, and props are not restored across a
+continuation, so a prop-borne or otherwise live value produces a different
+resolved layout from the same retained document. Those are what the comparison
+is for, and a change in either refuses before the foreground lease is taken and
+before any provider is contacted.
+
+Authored-structure change is therefore not a grid concern. A document whose body
+changed under an existing journal is a root-definition compatibility question —
+the retained root stays authoritative, and deciding whether a changed source
+should be refused rather than ignored belongs to a versioned root boundary that
+does not exist yet. Until it does, the grid's obligation is the narrower one it
+can actually discharge: retain the complete authored structure, and open the
+structure it retained rather than the one the file now shows. It rebuilds a fresh provider composite: completed pane
children are restored as settled statuses without re-running their effects,
while incomplete children replay or start their remaining work. An incomplete
`` preserves the prepared/detached identity rules of its own
@@ -4970,8 +5172,8 @@ Status is measured against main.
| testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them |
| nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack |
| `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack |
-| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; first production provider is tmux in the Deno and compiled foreground hosts; controlled non-tmux provider proves the core contract; implementation unbuilt |
-| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt |
+| grid (`` / ``) | replaces the root foreground presentation with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently usable, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease when its content requires terminal presentation, each interactive pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no grid, terminal, or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717 and renamed before delivery by #781; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start; DEC-016 specifies the final extraction into `@executablemd/grid` and `@executablemd/grid-tmux`, with every repository import moved to the canonical packages and all rejected unshipped names and paths deleted |
+| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt |
| `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack |
| `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 |
| `` | removes one file the document names, inside the contextual working directory. An ordinary overridable core default with a closed schema of one required non-empty `path`, **self-closing only** — a paired spelling never enters its body, because the component declares its one form and canonical invocation-form dispatch enters that body only for the form the scan recorded, before `Env.cwd` is read and before the provider is reached. Neither the composable `Component.hasContent()` chain nor a method on whatever object a caller handed over takes part. It renders the empty string, declares no `returns` and hands back no receipt, so an ordinary `as` captures that empty string; absence is the same success, so deleting a path twice succeeds twice. One regular file or one final symbolic link goes — the link rather than its target, inside or outside — and every directory is refused, an empty one included. Empty, absolute, lexically escaping and parent-link-escaping paths are refused before any removal, and a printed error names only the path the document wrote. One semantic `API.Files.deleteFile` call and no filesystem access of its own; under a workflow run it is one `workspace_file` effect retaining `{ kind: "deleted" }`. The standard Deno workflow profile admits it to generated XMD as the exact self-closing identity `@executablemd/core#File.Delete`, third in the write table, where it performs that same ordinary effect and contributes no evaluator result | built on the #567 stack |
diff --git a/bun.lock b/bun.lock
index e05f3854a..dd8a872e6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,7 +10,7 @@
"@effectionx/fetch": "0.2.1",
"@effectionx/fs": "0.3.0",
"@effectionx/middleware": "0.1.1",
- "@effectionx/node": "0.2.4",
+ "@effectionx/node": "0.2.5",
"@effectionx/process": "0.8.1",
"@effectionx/scope-eval": "0.1.3",
"@effectionx/stream-helpers": "0.8.3",
@@ -26,6 +26,7 @@
"mdast-util-to-string": "^4",
"remark": "15",
"remend": "^1.2.2",
+ "semver": "^7.8.5",
"unist-util-select": "^5",
"zod": "^4.3.6",
},
@@ -42,6 +43,7 @@
"@executablemd/testing": "workspace:*",
"@executablemd/workflow": "workspace:*",
"@types/node": "^22.0.0",
+ "@types/semver": "^7.7.0",
"expect": "^30.0.0",
"oxfmt": "^0.41.0",
"oxlint": "1.74.0",
@@ -53,7 +55,9 @@
"name": "@executablemd/acp",
"version": "0.12.1",
"dependencies": {
+ "@agentclientprotocol/sdk": "1.3.0",
"@executablemd/core": "workspace:*",
+ "@executablemd/grid": "workspace:*",
"@executablemd/runtime": "workspace:*",
"acpx": "0.12.0",
"effection": "4.1.0",
@@ -70,6 +74,8 @@
"@executablemd/acp": "workspace:*",
"@executablemd/core": "workspace:*",
"@executablemd/durable-streams": "workspace:*",
+ "@executablemd/grid": "workspace:*",
+ "@executablemd/grid-tmux": "workspace:*",
"@executablemd/runtime": "workspace:*",
"@executablemd/test-agent": "workspace:*",
"@executablemd/testing": "workspace:*",
@@ -78,6 +84,7 @@
"@standard-schema/spec": "^1.0.0",
"configliere": "^0.4.0",
"effection": "4.1.0",
+ "semver": "^7.8.5",
"zod": "^4.3.6",
},
},
@@ -94,12 +101,13 @@
"@effectionx/fetch": "0.2.1",
"@effectionx/fs": "0.3.0",
"@effectionx/middleware": "0.1.1",
- "@effectionx/node": "0.2.4",
+ "@effectionx/node": "0.2.5",
"@effectionx/process": "0.8.1",
"@effectionx/scope-eval": "0.1.3",
"@effectionx/stream-helpers": "0.8.3",
"@effectionx/timebox": "0.4.3",
"@executablemd/durable-streams": "workspace:*",
+ "@executablemd/grid": "workspace:*",
"@executablemd/runtime": "workspace:*",
"@secretlint/core": "13.0.4",
"@secretlint/profiler": "13.0.4",
@@ -125,6 +133,29 @@
"effection": "4.1.0",
},
},
+ "packages/grid": {
+ "name": "@executablemd/grid",
+ "version": "0.11.0",
+ "dependencies": {
+ "@effectionx/context-api": "0.6.0",
+ "@effectionx/fs": "0.3.0",
+ "@effectionx/node": "0.2.4",
+ "@effectionx/process": "0.8.1",
+ "@executablemd/durable-streams": "workspace:*",
+ "effection": "4.1.0",
+ },
+ },
+ "packages/grid-tmux": {
+ "name": "@executablemd/grid-tmux",
+ "version": "0.11.0",
+ "dependencies": {
+ "@effectionx/fs": "0.3.0",
+ "@effectionx/process": "0.8.1",
+ "@executablemd/grid": "workspace:*",
+ "effection": "4.1.0",
+ "zod": "^4.3.6",
+ },
+ },
"packages/runtime": {
"name": "@executablemd/runtime",
"version": "0.12.1",
@@ -132,7 +163,7 @@
"@effectionx/context-api": "0.6.0",
"@effectionx/fetch": "0.2.1",
"@effectionx/fs": "0.3.0",
- "@effectionx/node": "0.2.4",
+ "@effectionx/node": "0.2.5",
"@effectionx/process": "0.8.1",
"effection": "4.1.0",
},
@@ -142,12 +173,13 @@
"version": "0.12.1",
"dependencies": {
"@agentclientprotocol/sdk": "1.3.0",
- "@effectionx/node": "0.2.4",
+ "@effectionx/node": "0.2.5",
"@effectionx/scope-eval": "0.1.3",
"@effectionx/stream-helpers": "0.8.3",
"@executablemd/acp": "workspace:*",
"@executablemd/core": "workspace:*",
"@executablemd/durable-streams": "workspace:*",
+ "@executablemd/grid": "workspace:*",
"@executablemd/runtime": "workspace:*",
"@executablemd/testing": "workspace:*",
"acorn": "^8.16.0",
@@ -160,9 +192,11 @@
"name": "@executablemd/test-support",
"version": "0.0.0",
"dependencies": {
+ "@effectionx/fs": "0.3.0",
"@effectionx/process": "0.8.1",
"@effectionx/test-adapter": "0.7.4",
"@effectionx/timebox": "0.4.3",
+ "@executablemd/durable-streams": "workspace:*",
"effection": "4.1.0",
"expect": "^30.0.0",
},
@@ -176,6 +210,7 @@
"@effectionx/timebox": "0.4.3",
"@executablemd/core": "workspace:*",
"@executablemd/durable-streams": "workspace:*",
+ "@executablemd/runtime": "workspace:*",
"effection": "4.1.0",
},
},
@@ -183,7 +218,7 @@
"name": "@executablemd/web",
"version": "0.12.1",
"dependencies": {
- "@effectionx/node": "0.2.4",
+ "@effectionx/node": "0.2.5",
"@executablemd/core": "workspace:*",
"@executablemd/durable-streams": "workspace:*",
"@executablemd/runtime": "workspace:*",
@@ -212,7 +247,6 @@
"dependencies": {
"@effectionx/context-api": "0.6.0",
"@effectionx/fs": "0.3.0",
- "@effectionx/process": "0.8.1",
"@executablemd/core": "workspace:*",
"@executablemd/durable-streams": "workspace:*",
"@executablemd/runtime": "workspace:*",
@@ -249,7 +283,7 @@
"@effectionx/middleware": ["@effectionx/middleware@0.1.1", "", {}, "sha512-ss/bZRkt/xzJNE59r8NR1+0K/xQcIyCm0y9n8FYC8jKdFn51SPe3m3t7EfPcK8zkdjCoTOU7k1UpIXRl26asYA=="],
- "@effectionx/node": ["@effectionx/node@0.2.4", "", { "peerDependencies": { "effection": "^3 || ^4" } }, "sha512-cPnp3fvfBKjGWekmBHdhZr5ScAr3Mg+x5IXpO8uKFe7AZ8EPAT9Di6skuB4kuGFJtRtS0Z1e5G4+2eJyapKhYA=="],
+ "@effectionx/node": ["@effectionx/node@0.2.5", "", { "peerDependencies": { "effection": "^3 || ^4" } }, "sha512-hL8mROda8Lx375MVS+Ubu86+yMht/I0wOZG5VR6Pel0XUA5ReObQDYvNS6ocW0cNFKnEmvlVLWGWbcjJ+VkVhA=="],
"@effectionx/process": ["@effectionx/process@0.8.1", "", { "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/node": "0.2.4", "@effectionx/scope-eval": "0.1.3", "cross-spawn": "^7", "ctrlc-windows": "^2", "shellwords-ts": "^3.0.1" }, "peerDependencies": { "effection": "^3 || ^4" } }, "sha512-xyXlFja0Ill80lQ3IYfksXtJkqVmWuUOogRn/qlHWCAGlZj+MGGF8gOFbyzk/3Kx4pj14riVGgF/cyT5XCzqDw=="],
@@ -325,6 +359,10 @@
"@executablemd/durable-streams": ["@executablemd/durable-streams@workspace:packages/durable-streams"],
+ "@executablemd/grid": ["@executablemd/grid@workspace:packages/grid"],
+
+ "@executablemd/grid-tmux": ["@executablemd/grid-tmux@workspace:packages/grid-tmux"],
+
"@executablemd/runtime": ["@executablemd/runtime@workspace:packages/runtime"],
"@executablemd/test-agent": ["@executablemd/test-agent@workspace:packages/test-agent"],
@@ -585,6 +623,8 @@
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
+ "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="],
+
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
@@ -937,6 +977,8 @@
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "2.0.1", "kind-of": "6.0.3" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
+ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
+
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@@ -1049,10 +1091,16 @@
"@durable-streams/state/@durable-streams/client": ["@durable-streams/client@0.2.6", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", "fastq": "^1.19.1" }, "bin": { "intent": "bin/intent.js" } }, "sha512-uHKKbWpsKLhFMeGjG0PgM6LXE3oEIi7FHKlJZkmYGxcqd4Yjjd/QEvnQnDzteRP4Av1uJVM8qjTL7kfKsgeS/w=="],
+ "@effectionx/process/@effectionx/node": ["@effectionx/node@0.2.4", "", { "peerDependencies": { "effection": "^3 || ^4" } }, "sha512-cPnp3fvfBKjGWekmBHdhZr5ScAr3Mg+x5IXpO8uKFe7AZ8EPAT9Di6skuB4kuGFJtRtS0Z1e5G4+2eJyapKhYA=="],
+
"@executablemd/cli/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@executablemd/durable-streams/@durable-streams/client": ["@durable-streams/client@0.2.6", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", "fastq": "^1.19.1" }, "bin": { "intent": "bin/intent.js" } }, "sha512-uHKKbWpsKLhFMeGjG0PgM6LXE3oEIi7FHKlJZkmYGxcqd4Yjjd/QEvnQnDzteRP4Av1uJVM8qjTL7kfKsgeS/w=="],
+ "@executablemd/grid/@effectionx/node": ["@effectionx/node@0.2.4", "", { "peerDependencies": { "effection": "^3 || ^4" } }, "sha512-cPnp3fvfBKjGWekmBHdhZr5ScAr3Mg+x5IXpO8uKFe7AZ8EPAT9Di6skuB4kuGFJtRtS0Z1e5G4+2eJyapKhYA=="],
+
+ "@executablemd/grid-tmux/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
+
"@executablemd/test-agent/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
diff --git a/deno.lock b/deno.lock
index 7772922ea..1f47f40b5 100644
--- a/deno.lock
+++ b/deno.lock
@@ -48,6 +48,7 @@
"npm:@effectionx/fetch@0.2.1": "0.2.1_effection@4.1.0",
"npm:@effectionx/fs@0.3.0": "0.3.0_effection@4.1.0",
"npm:@effectionx/middleware@0.1.1": "0.1.1",
+ "npm:@effectionx/node@0.2.4": "0.2.4_effection@4.1.0",
"npm:@effectionx/node@0.2.5": "0.2.5_effection@4.1.0",
"npm:@effectionx/process@0.8.1": "0.8.1_effection@4.1.0",
"npm:@effectionx/scope-eval@0.1.3": "0.1.3_effection@4.1.0",
@@ -4117,6 +4118,27 @@
]
}
},
+ "packages/grid": {
+ "packageJson": {
+ "dependencies": [
+ "npm:@effectionx/context-api@0.6.0",
+ "npm:@effectionx/fs@0.3.0",
+ "npm:@effectionx/node@0.2.4",
+ "npm:@effectionx/process@0.8.1",
+ "npm:effection@4.1.0"
+ ]
+ }
+ },
+ "packages/grid-tmux": {
+ "packageJson": {
+ "dependencies": [
+ "npm:@effectionx/fs@0.3.0",
+ "npm:@effectionx/process@0.8.1",
+ "npm:effection@4.1.0",
+ "npm:zod@^4.3.6"
+ ]
+ }
+ },
"packages/runtime": {
"packageJson": {
"dependencies": [
diff --git a/packages/acp/package.json b/packages/acp/package.json
index 18bf4c5d8..1d99f8295 100644
--- a/packages/acp/package.json
+++ b/packages/acp/package.json
@@ -10,6 +10,7 @@
"dependencies": {
"@agentclientprotocol/sdk": "1.3.0",
"@executablemd/core": "workspace:*",
+ "@executablemd/grid": "workspace:*",
"@executablemd/runtime": "workspace:*",
"acpx": "0.12.0",
"effection": "4.1.0"
diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts
index a7dc7ba90..b1a3a4d16 100644
--- a/packages/acp/src/provider.ts
+++ b/packages/acp/src/provider.ts
@@ -22,6 +22,7 @@
import {
createChannel,
+ createScope,
ensure,
Err,
Ok,
@@ -87,8 +88,8 @@ import {
AgentSessionRecoveryRequired,
cwd,
ExecutableObservationError,
- nativeLaunch,
} from "@executablemd/runtime";
+import { nativeLaunch } from "@executablemd/grid";
import type {
AgentSessionCoordinator,
AgentSessionKey,
@@ -2756,24 +2757,57 @@ function* useAcpxProviderState(
// the reader's terminal while offering no way to reach the owner it
// was waiting for. It refuses instead, and the coordinator is what
// refuses it.
- yield* authority.perform(request, {
- prepare: () =>
- withSessionRoute(context, () =>
- prepareLaunch(invocation, agentName, callerCwd, request.instructions, placement),
- ),
- detach: (prepared) => detachSession(invocation, prepared, agentCommandOf(placement)),
- exit: (prepared) => runNativeUi(invocation, prepared, agentCommandOf(placement)),
+ //
+ // The launch runs in a scope of its own so that this owner can bring
+ // it down deliberately and watch how that goes. A cancelled launch —
+ // the reader closing a grid is one — unwinds past every
+ // statement after it, so a decision written down here would never be
+ // reached; written as this scope's cleanup, it is reached on every
+ // path there is.
+ const [running, stop] = createScope(yield* useScope());
+ let stopped = false;
+
+ yield* ensure(function* () {
+ // Registered after the scope exists, so it runs before the scope
+ // is destroyed on its own: the launch comes down here, and
+ // `destroy()` carries the outcome of its teardown. A child that
+ // could not be proven stopped, or a cleanup that failed, throws
+ // out of it — and is not quiescence, and is still a failure.
+ try {
+ yield* until(stop());
+ stopped = true;
+ } finally {
+ // Everything this owner started has to be finished with the
+ // session, and that is two facts rather than one: the native
+ // child and its cleanup settled, and this provider holds no
+ // handle for the session — a detach that failed, or a session
+ // prepared and never handed over, leaves one. Either one
+ // missing leaves the session owned rather than looking
+ // finished, which is what the next owner is told to recover
+ // deliberately.
+ if (stopped && !holding(placement.sessionKey)) {
+ ownership.quiesced();
+ }
+ }
});
- // Only here, and only once this provider is holding nothing. By the
- // time `perform` returns the native child has exited and been reaped,
- // so what is left to check is the ACP handle: a handoff that released
- // it quiesces, and one that could not — a detach that failed, a
- // session prepared but never handed over — leaves the session owned
- // rather than looking finished.
- if (!holding(placement.sessionKey)) {
- ownership.quiesced();
- }
+ yield* running.run(() =>
+ authority.perform(request, {
+ prepare: () =>
+ withSessionRoute(context, () =>
+ prepareLaunch(
+ invocation,
+ agentName,
+ callerCwd,
+ request.instructions,
+ placement,
+ ),
+ ),
+ detach: (prepared) =>
+ detachSession(invocation, prepared, agentCommandOf(placement)),
+ exit: (prepared) => runNativeUi(invocation, prepared, agentCommandOf(placement)),
+ }),
+ );
},
);
} catch (error) {
diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts
index 7c3df4976..6961108fc 100644
--- a/packages/acp/tests/native-launch.test.ts
+++ b/packages/acp/tests/native-launch.test.ts
@@ -27,8 +27,10 @@ import type {
PreparedLaunchRecord,
Session,
} from "@executablemd/core";
-import { flushOutput, installControlledLauncher, reserveTerminal } from "@executablemd/runtime";
-import type { AgentSessionCoordinator, NativeLaunchRequest } from "@executablemd/runtime";
+import { flushOutput, NativeLauncher, reserveTerminal } from "@executablemd/grid";
+import { installControlledLauncher } from "@executablemd/grid/test";
+import type { AgentSessionCoordinator } from "@executablemd/runtime";
+import type { NativeLaunchRequest } from "@executablemd/grid";
import { createAcpxProvider } from "../src/provider.ts";
import type { AcpxProviderDependencies } from "../src/provider.ts";
import {
@@ -206,6 +208,14 @@ interface ProviderOptions {
withSessionRoute?: AcpxProviderDependencies["withSessionRoute"];
/** Blocks the native child until this resolves. */
hold?: Operation;
+ /**
+ * Make the launch's own teardown fail, in place of a child that cannot be
+ * proven stopped.
+ *
+ * Composed in front of the launcher rather than replacing it, so what fails
+ * is the cleanup of a launch that was otherwise ordinary.
+ */
+ cleanupFails?: string;
onLaunch?: () => void;
exitCode?: number;
/**
@@ -328,6 +338,20 @@ function* installLaunchStack(
outcome: () => ({ exitCode: options.exitCode ?? 0 }),
});
+ if (options.cleanupFails !== undefined) {
+ const reason = options.cleanupFails;
+ yield* NativeLauncher.around({
+ *launch([request, spawned], next) {
+ // Registered inside the launch, so it unwinds with it — and refuses to
+ // say the child is gone.
+ yield* ensure(function* () {
+ throw new Error(reason);
+ });
+ return yield* next(request, spawned);
+ },
+ });
+ }
+
const factory = createAcpxProvider({
createRuntime: harness.create,
sessionStore: options.store ?? makeStore(),
@@ -2518,11 +2542,51 @@ describe("Tier CX — cancellation before ownership ends", () => {
),
),
).toBe(false);
- const released = trace.ownership.events.indexOf("released-active");
+ const released = trace.ownership.events.indexOf("released-idle");
expect(trace.ownership.events.indexOf("cancelling") < released).toBe(true);
- // A launch that stopped on the way never proved the session stopped, so it
- // stays owned rather than looking finished.
+ // An orderly stop that finished is a stop. The child was proven gone, its
+ // cleanup settled, and this provider held no handle for the session — so
+ // nothing this owner started can still act on it, which is exactly what
+ // quiescence acknowledges. Withholding it here would leave a recovery
+ // tombstone for a cancellation that had already proved everything a normal
+ // return proves.
+ expect(trace.ownership.events).toContain("quiesced");
+ expect(trace.ownership.events).not.toContain("released-active");
+ });
+
+ it("CX2: a cancellation whose cleanup could not finish stays owned", function* () {
+ const harness = createFakeRuntime();
+ const trace = newTrace();
+ const hold = withResolvers();
+ const started = withResolvers();
+ let halting = "";
+
+ yield* scoped(function* () {
+ yield* installLaunchStack(harness, trace, {
+ routeStore: createMemorySessionRouteStore(),
+ cleanupFails: "the native child could not be proven stopped",
+ hold: (function* () {
+ started.resolve();
+ yield* hold.operation;
+ })(),
+ });
+
+ const launching = yield* spawn(() => Agent.operations.launch(launchRequest(INSTRUCTIONS)));
+ yield* started.operation;
+ try {
+ yield* launching.halt();
+ } catch (error) {
+ halting = error instanceof Error ? error.message : String(error);
+ }
+ });
+
+ // The teardown failed, and said so rather than passing quietly.
+ expect(halting).toContain("could not be proven stopped");
+ // So nothing was acknowledged: a cancellation is not evidence on its own,
+ // and neither is the lease coming back. The session stays owned, and the
+ // next owner is told to recover it deliberately.
expect(trace.ownership.events).not.toContain("quiesced");
+ expect(trace.ownership.events).toContain("released-active");
});
});
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 629ad2ea6..b32527c11 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -15,6 +15,8 @@
"@executablemd/acp": "workspace:*",
"@executablemd/core": "workspace:*",
"@executablemd/durable-streams": "workspace:*",
+ "@executablemd/grid": "workspace:*",
+ "@executablemd/grid-tmux": "workspace:*",
"@executablemd/runtime": "workspace:*",
"@executablemd/test-agent": "workspace:*",
"@executablemd/testing": "workspace:*",
diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts
index 4fdb23ab3..64bf428c1 100644
--- a/packages/cli/src/agent-stack.ts
+++ b/packages/cli/src/agent-stack.ts
@@ -21,7 +21,10 @@ import {
registerAgentProvider,
} from "@executablemd/core";
import type { AgentProviderFactory, PermissionMode } from "@executablemd/core";
-import { installForegroundLauncher, env as readEnv } from "@executablemd/runtime";
+import { env as readEnv } from "@executablemd/runtime";
+import { installForegroundLauncher } from "@executablemd/grid/posix";
+import { unsupportedGrid } from "./grid-host.ts";
+import type { GridInstaller } from "./grid-host.ts";
import { createAcpxProvider, DEFAULT_AGENT_NAME } from "@executablemd/acp";
import type { AcpxProviderDependencies } from "@executablemd/acp";
// A separate entrypoint because the embedded adapters are temporary (#636) and
@@ -67,6 +70,14 @@ export interface PlanWriterStack {
adapters: EmbeddedAdapters;
/** What this host states about machine-wide agent sessions, if anything. */
sessions?: MachineSessionAssembly;
+ /**
+ * What presents this host's grids.
+ *
+ * Deno and the compiled binary supply the tmux provider; Node and Bun supply
+ * the one that installs none, so those runtimes describe and validate the
+ * same grids and open none of them.
+ */
+ installGrid?: GridInstaller;
}
/** Everything one `xmd run` invocation settled about agents, resolved once. */
@@ -106,6 +117,7 @@ export function* resolvePlanWriterStack(
export function* resolveAgentStack(
flags: AgentFlags,
sessions: MachineSessionAssembly | undefined,
+ installGrid?: GridInstaller,
): Operation> {
const config = resolveAgentConfig(flags);
if ("error" in config) {
@@ -118,7 +130,11 @@ export function* resolveAgentStack(
if (!planWriter.ok) {
return planWriter;
}
- return Ok({ ...planWriter.value, permissionMode: config.permissionMode });
+ return Ok({
+ ...planWriter.value,
+ permissionMode: config.permissionMode,
+ ...(installGrid === undefined ? {} : { installGrid }),
+ });
}
/**
@@ -179,4 +195,8 @@ export function* installRunAgentStack(stack: AgentStack): Operation {
// document inspection and `xmd test` install no launcher, so a document that
// reaches under any of them refuses instead of spawning.
yield* installForegroundLauncher();
+ // And whatever presents this host's grids, which on a host that
+ // presents none still opens the installation so a grid is validated — the
+ // refusal a document meets there is core's own.
+ yield* (stack.installGrid ?? unsupportedGrid)();
}
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index aa70c95b9..463fc1553 100755
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -96,6 +96,8 @@ import { installWebComponents, installWebElicitation } from "@executablemd/web";
import { timebox } from "@effectionx/timebox";
import { timeout as runTimeout } from "@executablemd/runtime";
import { installRunAgentStack, resolveAgentStack, resolvePlanWriterStack } from "./agent-stack.ts";
+import { unsupportedGrid } from "./grid-host.ts";
+import type { GridInstaller } from "./grid-host.ts";
import { planComponentDeclaration } from "./plan-component.ts";
import { planAgentContext } from "./plan-writer-profile.ts";
import { useVerboseComponent } from "./verbose-component.ts";
@@ -781,8 +783,9 @@ function* underRunDeadline(timeouts: RunTimeouts, body: () => Operation):
function* settleAgentStack(
flags: AgentFlags,
sessions: MachineSessionAssembly | undefined,
+ installGrid: GridInstaller,
): Operation {
- const stack = yield* resolveAgentStack(flags, sessions);
+ const stack = yield* resolveAgentStack(flags, sessions, installGrid);
if (!stack.ok) {
console.error(stack.error.message);
yield* exit(1);
@@ -2368,6 +2371,7 @@ function* dispatch(
readStandardInput: StandardInputReader,
workflowHost: WorkflowHost | undefined,
sessions: MachineSessionAssembly | undefined,
+ installGrid: GridInstaller,
): Operation {
// Before the props phase, and before the help short-circuit below. `--help`
// is lifted out of argv early enough that a command's own grammar never sees
@@ -2466,6 +2470,7 @@ function* dispatch(
denyAll: config.denyAll,
},
sessions,
+ installGrid,
);
if (runStack === undefined) {
break;
@@ -2836,6 +2841,10 @@ export function* runXmd(
// owns the session or which build it belongs to. A caller that names none
// gets no machine sessions at all, which is the ordinary ACP behaviour.
sessions?: MachineSessionAssembly,
+ // What presents a grid on this host. Deno and the compiled binary
+ // supply the tmux provider; Node and Bun supply the one that installs none,
+ // so those runtimes describe and validate the same grids and open none.
+ installGrid: GridInstaller = unsupportedGrid,
): Operation {
// Before every scanner, before command selection, and before anything reads a
// path. `prompt` names no command, and a first token that names none is a
@@ -2905,6 +2914,7 @@ export function* runXmd(
readStandardInput,
workflowHost,
sessions,
+ installGrid,
);
}
@@ -2927,6 +2937,7 @@ export function* runXmd(
readStandardInput,
workflowHost,
sessions,
+ installGrid,
),
);
}
diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts
index 3a94a6b1e..d15c67421 100644
--- a/packages/cli/src/compiled.ts
+++ b/packages/cli/src/compiled.ts
@@ -19,6 +19,8 @@ import {
isCredentialHelperMode,
runCredentialHelper,
} from "@executablemd/workflow/credential-helper";
+import { paneWorkerInvocation, runPaneWorkerProcess } from "@executablemd/grid-tmux";
+import { foregroundGrid } from "./grid-host.ts";
import type { HelperAssembly } from "@executablemd/workflow/credential-helper";
import { useCompiledService } from "./compiled-service.ts";
@@ -50,7 +52,13 @@ const UPGRADE = compiledUpgradeAssembly({
});
// Before anything public is parsed, and absent from every public surface.
-if (isCredentialHelperMode(process.argv.slice(2))) {
+const paneWorker = paneWorkerInvocation(process.argv.slice(2));
+if (paneWorker !== undefined) {
+ // Not `main()`: it would bind SIGINT to its own shutdown and exit 130 on the
+ // first `^C` typed into the pane, which is the keystroke the foreground child
+ // is supposed to receive.
+ await runPaneWorkerProcess(paneWorker);
+} else if (isCredentialHelperMode(process.argv.slice(2))) {
await main(() => runCredentialHelper(process.argv.slice(2)));
} else {
await main(function* (args) {
@@ -93,6 +101,9 @@ if (isCredentialHelperMode(process.argv.slice(2))) {
() => readInputStream(process.stdin),
() => useDenoWorkflowHost(HELPER),
useMachineSessions(),
+ // This host presents grids: it has a terminal to divide, and it can
+ // re-invoke itself for one pane.
+ foregroundGrid(),
);
});
}
diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts
index 3bc6b901e..c3ae4e114 100644
--- a/packages/cli/src/deno.ts
+++ b/packages/cli/src/deno.ts
@@ -22,6 +22,8 @@ import {
isCredentialHelperMode,
runCredentialHelper,
} from "@executablemd/workflow/credential-helper";
+import { paneWorkerInvocation, runPaneWorkerProcess } from "@executablemd/grid-tmux";
+import { foregroundGrid } from "./grid-host.ts";
import type { HelperAssembly } from "@executablemd/workflow/credential-helper";
import { useDenoService } from "./deno-service.ts";
@@ -66,7 +68,13 @@ const UPGRADE: UpgradeAssembly = {
// The internal helper mode runs before anything public is parsed. It is not a
// command: it appears in no help and in no public grammar, and a caller who did
// not select it gets the ordinary command line unchanged.
-if (isCredentialHelperMode(process.argv.slice(2))) {
+const paneWorker = paneWorkerInvocation(process.argv.slice(2));
+if (paneWorker !== undefined) {
+ // Not `main()`: it would bind SIGINT to its own shutdown and exit 130 on the
+ // first `^C` typed into the pane, which is the keystroke the foreground child
+ // is supposed to receive.
+ await runPaneWorkerProcess(paneWorker);
+} else if (isCredentialHelperMode(process.argv.slice(2))) {
await main(() => runCredentialHelper(process.argv.slice(2)));
} else {
await main(function* (args) {
@@ -112,6 +120,9 @@ if (isCredentialHelperMode(process.argv.slice(2))) {
() => readInputStream(process.stdin),
() => useDenoWorkflowHost(HELPER),
useMachineSessions(),
+ // This host presents grids: it has a terminal to divide, and it can
+ // re-invoke itself for one pane.
+ foregroundGrid(),
);
});
}
diff --git a/packages/cli/src/grid-host.ts b/packages/cli/src/grid-host.ts
new file mode 100644
index 000000000..05272fad5
--- /dev/null
+++ b/packages/cli/src/grid-host.ts
@@ -0,0 +1,205 @@
+/**
+ * Which hosts open a grid, and which only describe one
+ * (architecture.md §Package ownership).
+ *
+ * Host composition, not a terminal implementation — which is why it sits here
+ * rather than under a `terminal/` path. The domain is
+ * `@executablemd/grid`'s and the provider is `@executablemd/grid-tmux`'s;
+ * what this module does is decide, per entrypoint, whether to install them.
+ *
+ * The Deno source entrypoint and the compiled binary present grids when the
+ * invocation has a terminal and a usable tmux. Node and Bun keep the same
+ * language, catalog and validation and install no operational provider — a
+ * document that asks for a grid there is refused before a pane starts, rather
+ * than part-way through one.
+ *
+ * That is a fact about the host, so the entrypoint states it rather than this
+ * module inferring it. `unsupportedGrid` is the honest half of the same
+ * choice: it installs nothing, and the refusal a document meets is the one core
+ * already gives when no provider is installed.
+ */
+
+import { ensure, race, resource, withResolvers } from "effection";
+import type { Operation } from "effection";
+import process from "node:process";
+import { Execution, installGridProfile } from "@executablemd/core";
+import { command as hostCommand } from "@executablemd/runtime";
+import { installDenoTerminalProcesses } from "@executablemd/grid/posix";
+import {
+ installTmuxGridProvider,
+ PANE_WORKER_COMMAND,
+ TMUX_PROVIDER,
+} from "@executablemd/grid-tmux";
+import type { TmuxProviderDependencies } from "@executablemd/grid-tmux";
+
+/** How a host installs whatever presents its grids. */
+export type GridInstaller = () => Operation;
+
+/**
+ * A host that describes grids and presents none.
+ *
+ * Not an error, and not silence either: the installation is opened so a grid is
+ * still validated, and core's own refusal is what a document meets when it asks
+ * for one to be shown.
+ */
+export function* unsupportedGrid(): Operation {
+ yield* installGridProfile();
+}
+
+/**
+ * Make the host's terminal going away cancel the document.
+ *
+ * Not a reader close. A reader who detaches has finished with a grid, and the
+ * grid settles with a reader-close outcome and the document carries on. A
+ * terminal that is *gone* is not a decision about this grid — it is the run
+ * losing the thing every part of it was drawing on, so the document is
+ * cancelled through the ordinary structured path: the grid's whole teardown
+ * runs, and no following sibling gets to go.
+ */
+export function useHangupCancellation(hangup: Operation): Operation {
+ return Execution.around({
+ *document([request], next) {
+ // The result is returned, not swallowed: canonical execution is what
+ // produces a document result, and a handler that answered with nothing
+ // would be refused for having returned before one existed.
+ return yield* underHangup(hangup, () => next(request));
+ },
+ });
+}
+
+/**
+ * Run `body`, and cancel it if the terminal goes away first.
+ *
+ * The losing side of the race is cancelled, which is the whole point: the grid
+ * comes down through the same teardown a reader close uses, and the run stops
+ * rather than continuing on a terminal it no longer has.
+ */
+export function underHangup(hangup: Operation, body: () => Operation): Operation {
+ return (function* (): Operation {
+ const outcome = yield* race([
+ (function* (): Operation<{ done: true; value: T }> {
+ return { done: true, value: yield* body() };
+ })(),
+ (function* (): Operation<{ done: false }> {
+ yield* hangup;
+ return { done: false };
+ })(),
+ ]);
+ if (!outcome.done) {
+ throw new TerminalLost();
+ }
+ return outcome.value;
+ })();
+}
+
+/** The host's terminal went away while the document was still running. */
+export class TerminalLost extends Error {
+ override name = "TerminalLost";
+ constructor() {
+ super(
+ "this run's terminal went away, so the document was stopped. Anything it " +
+ "had shown is gone with the terminal; nothing after the point it stopped ran.",
+ );
+ }
+}
+
+/**
+ * The environment every process in the topology receives.
+ *
+ * Named rather than inherited wholesale: a pane's child gets what a terminal
+ * program needs and nothing this process happens to be carrying.
+ *
+ * It is a host decision, so it is made here rather than by the provider. The
+ * adapter is handed an environment and passes exactly that along; which of
+ * *this* invocation's variables are worth passing is a question only the
+ * entrypoint composing the host can answer.
+ */
+function paneEnvironment(source: Record): Record {
+ const env: Record = {};
+ for (const name of [
+ "PATH",
+ "HOME",
+ "SHELL",
+ "LANG",
+ "TMPDIR",
+ "USER",
+ "LOGNAME",
+ // What a terminal program reads to decide it may use 24-bit colour.
+ // Passed through when this host has it, absent when it does not: naming a
+ // capability the reader's terminal lacks is worse than leaving a program
+ // on the 256 colours `TERM` already promises. It is named here because a
+ // pane's direct child reads none of the reader's shell startup — a
+ // variable their `.zshrc` exports reaches an interactive shell in a pane
+ // and nothing else, which is exactly the difference this closes.
+ "COLORTERM",
+ ]) {
+ const value = source[name];
+ if (value !== undefined && value !== "") {
+ env[name] = value;
+ }
+ }
+ env.TERM = source.TERM ?? "xterm-256color";
+ return env;
+}
+
+/** The terminal this run is drawing on, as tmux needs to know it. */
+function windowSize(): { columns: number; rows: number } {
+ // A terminal that cannot say gets the sizes tmux itself defaults to, which is
+ // better than a grid that refuses to lay out at all.
+ return {
+ columns: process.stdout.columns ?? 80,
+ rows: process.stdout.rows ?? 24,
+ };
+}
+
+/**
+ * Settle when this process's terminal goes away.
+ *
+ * SIGHUP is the terminal saying it is gone. What follows is the ordinary
+ * structured cancellation a reader's close would cause — the grid comes down
+ * the same way, through the same teardown, rather than through a second path
+ * that would have to be kept honest separately.
+ *
+ * Registered as a resource so the handler is removed with the run: a listener
+ * that outlived its grid would answer for a terminal the next one is using.
+ */
+export function useHangup(): Operation> {
+ return resource>(function* (provide) {
+ const hung = withResolvers();
+ const onHangup = (): void => hung.resolve();
+ process.on("SIGHUP", onHangup);
+ yield* ensure(() => {
+ // Removed with the run that installed it. A listener that outlived its
+ // grid would answer for a terminal the next one is using.
+ process.off("SIGHUP", onHangup);
+ });
+ yield* provide(hung.operation);
+ });
+}
+
+/**
+ * Install the tmux provider for a foreground host.
+ *
+ * `workerCommand` is how this host re-invokes itself for one pane. Reusing the
+ * executable is what makes a pane work in the compiled distribution, where
+ * there is no script to run.
+ */
+export function foregroundGrid(overrides: Partial = {}): GridInstaller {
+ return function* (): Operation {
+ const hangup = yield* useHangup();
+ // The observer goes in beside the provider, in the same scope: a host that
+ // presents grids is exactly the host that has to prove a pane is free, and
+ // one that installs neither refuses rather than guessing at either.
+ yield* installDenoTerminalProcesses();
+ yield* installTmuxGridProvider({
+ isTerminal: () => process.stdout.isTTY === true,
+ env: paneEnvironment(process.env),
+ workerCommand: (ordinal, directory) =>
+ hostCommand([PANE_WORKER_COMMAND, String(ordinal), directory]),
+ size: windowSize,
+ ...overrides,
+ });
+ yield* installGridProfile({ provider: TMUX_PROVIDER, label: TMUX_PROVIDER });
+ yield* useHangupCancellation(hangup);
+ };
+}
diff --git a/packages/cli/tests/agent-session-coordinator.test.ts b/packages/cli/tests/agent-session-coordinator.test.ts
index 46464641c..78c1c4bf4 100644
--- a/packages/cli/tests/agent-session-coordinator.test.ts
+++ b/packages/cli/tests/agent-session-coordinator.test.ts
@@ -28,8 +28,8 @@ import {
API,
createDenoAgentSessionCoordinator,
hasDenoAgentSessionCoordinator,
- installControlledLauncher,
} from "@executablemd/runtime";
+import { installControlledLauncher } from "@executablemd/grid/test";
import type { AgentSessionCoordinator } from "@executablemd/runtime";
import {
ADVERTISED_CLIENT_NATIVE_ATTACHMENT,
@@ -39,7 +39,8 @@ import {
createMemorySessionRouteStore,
} from "@executablemd/acp";
import type { AgentSessionRouteStore, NativeAdapter, NativeBinding } from "@executablemd/acp";
-import type { ExecutableObserver, NativeLaunchRequest } from "@executablemd/runtime";
+import type { ExecutableObserver } from "@executablemd/runtime";
+import type { NativeLaunchRequest } from "@executablemd/grid";
import { createFakeObserver } from "../../acp/tests/helpers.ts";
import {
sessionCoordinatorRoot,
diff --git a/packages/cli/tests/grid-host.test.ts b/packages/cli/tests/grid-host.test.ts
new file mode 100644
index 000000000..77dbc912b
--- /dev/null
+++ b/packages/cli/tests/grid-host.test.ts
@@ -0,0 +1,586 @@
+/**
+ * Tier TH — which hosts open a grid, and which only describe one
+ * (architecture.md §Package ownership, issue #717).
+ *
+ * The host-composition boundary is CLI's, so its evidence is too. The tmux
+ * adapter's own topology, protocol, worker and teardown rows live with the
+ * adapter in `@executablemd/grid-tmux`; what is proved here is the part
+ * only an entrypoint can answer — which runtime installs a provider and an
+ * observer, which installs neither, what a real document gets in each case,
+ * and that a terminal going away cancels the run rather than closing the grid.
+ *
+ * The fake tmux server and its client fixtures are imported from the adapter's
+ * own tests. That is a test-only path: it creates no package dependency, and
+ * the production graph CLI declares is unchanged by it.
+ */
+import { describe, it } from "@executablemd/test-support/bdd";
+import { expect } from "@executablemd/test-support/expect";
+import { ensure, Ok, resource, scoped, sleep, spawn, until, withResolvers } from "effection";
+import type { Operation, Result } from "effection";
+import type { ChildProcess } from "node:child_process";
+import * as path from "node:path";
+import process from "node:process";
+import { chmod, readdir } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { randomUUID } from "node:crypto";
+import { cliCommand } from "@executablemd/test-support/launch";
+import { ensureDir, exists, readTextFile, rm, writeTextFile } from "@effectionx/fs";
+import { execute } from "@executablemd/core";
+import { installGridProvider, useGridInstallation } from "@executablemd/grid/lifecycle";
+import type { Json } from "@executablemd/core";
+import { InMemoryStream } from "@executablemd/durable-streams";
+import { registerGridProvider, Grids } from "@executablemd/grid";
+import { installControlledLauncher } from "@executablemd/grid/test";
+import { processReachable } from "@executablemd/grid/processes";
+import { installDenoTerminalProcesses } from "@executablemd/grid/posix";
+import { PANE_WORKER_COMMAND, tmuxGridProvider } from "@executablemd/grid-tmux";
+import { foregroundSignalListeners } from "@executablemd/grid-tmux/test";
+import { createFakeTmux } from "../../grid-tmux/tests/fixtures/fake-tmux.ts";
+import { clientCommand } from "../../grid-tmux/tests/fixtures/client-command.ts";
+import { foregroundGrid, unsupportedGrid } from "../src/grid-host.ts";
+
+/** Where a fake server and its client fixtures meet. */
+function useScript(): Operation {
+ return resource(function* (provide) {
+ const file = path.join(tmpdir(), `xmd-tmux-script-${randomUUID()}.txt`);
+ yield* writeTextFile(file, "");
+ yield* ensure(function* () {
+ yield* rm(file, { force: true });
+ });
+ yield* provide(file);
+ });
+}
+
+/**
+ * Open a grid through the provider, with the host's prerequisites answered by
+ * this row rather than by the machine.
+ *
+ * Goes through the real factory and the real installation handshake, so what a
+ * refusal proves is what a document would meet.
+ */
+function useProbedProvider(options: {
+ isTerminal: () => boolean;
+ version?: string;
+}): Operation {
+ return (function* (): Operation {
+ const authority = yield* useGridInstallation();
+ yield* registerGridProvider(
+ "tmux",
+ tmuxGridProvider({
+ isTerminal: options.isTerminal,
+ env: { PATH: "/usr/bin:/bin" },
+ // deno-lint-ignore require-yield
+ *workerCommand() {
+ return [];
+ },
+ size: () => ({ columns: 80, rows: 24 }),
+ ...(options.version === undefined
+ ? {}
+ : {
+ // deno-lint-ignore require-yield
+ *askVersion() {
+ return { code: 0, stdout: options.version ?? "" };
+ },
+ }),
+ }),
+ );
+ yield* installGridProvider("tmux", { label: "tmux" }, authority);
+ yield* Grids.operations.open({
+ columns: 1,
+ rows: 1,
+ panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }],
+ });
+ })();
+}
+
+/** A directory a row can leave markers in. */
+function useScratch(): Operation {
+ return resource(function* (provide) {
+ const room = path.join(tmpdir(), `xmd-tg20-${randomUUID()}`);
+ yield* ensureDir(room);
+ yield* ensure(function* () {
+ yield* rm(room, { recursive: true, force: true });
+ });
+ yield* provide(room);
+ });
+}
+
+/** Settle once this child has gone, whether or not it already had. */
+function exited(child: ChildProcess): Operation {
+ const done = withResolvers();
+ const onExit = (): void => done.resolve();
+ if (child.exitCode !== null || child.signalCode !== null) {
+ done.resolve();
+ } else {
+ child.on("exit", onExit);
+ }
+ return (function* (): Operation {
+ try {
+ yield* done.operation;
+ } finally {
+ child.off("exit", onExit);
+ }
+ })();
+}
+
+/** A shell that says when it started, and stays until it is signalled. */
+function useShellFixture(room: string): Operation {
+ return resource(function* (provide) {
+ const file = path.join(room, "shell");
+ yield* writeTextFile(
+ file,
+ [
+ "#!/bin/sh",
+ // Its own environment, before anything else. A plain script sources no
+ // startup file, so what this records is what the pane handed it rather
+ // than what a `.zshrc` added afterwards.
+ `env > "${room}/shell-env"`,
+ `echo $$ > "${room}/shell-pid"`,
+ "while true; do sleep 0.05; done",
+ "",
+ ].join("\n"),
+ );
+ yield* until(chmod(file, 0o755));
+ yield* provide(file);
+ });
+}
+
+/** One entrypoint's source, for the rows about what a host assembles. */
+function entrypointSource(name: string): Operation {
+ return readTextFile(path.resolve("packages/cli/src", name));
+}
+
+describe("Tier TH — host installation", () => {
+ it("TD9: a teardown that fails refuses the run, and nothing after the grid goes", function* () {
+ // The document-level end of the same claim: a grid whose teardown could not
+ // establish the terminal was given back is a failed run, not a run with a
+ // warning in it.
+ const room = yield* useScratch();
+ const shell = yield* useShellFixture(room);
+ const script = yield* useScript();
+ const invocation = cliCommand([]);
+ // The server refuses to be killed the first time it is asked, so the last
+ // phase of the teardown cannot establish it is gone.
+ const tmux = createFakeTmux({
+ script,
+ clientCommand,
+ spawnPanes: true,
+ failOnce: { command: "kill-server", message: "refused" },
+ });
+ yield* ensure(() => {
+ tmux.stopPanes();
+ });
+ yield* writeTextFile(
+ path.join(room, "doc.md"),
+ ["", '', "", "", "AFTER_THE_GRID", ""].join(
+ "\n",
+ ),
+ );
+ yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) });
+
+ let outcome: Result | undefined;
+ let output = "";
+ yield* scoped(function* () {
+ yield* foregroundGrid({
+ isTerminal: () => true,
+ createTmux: () => tmux,
+ env: { PATH: "/usr/bin:/bin", SHELL: shell },
+ // deno-lint-ignore require-yield
+ *askVersion() {
+ return { code: 0, stdout: "tmux 3.6a" };
+ },
+ workerCommand: function* (ordinal, at) {
+ return [
+ invocation.command,
+ ...invocation.arguments,
+ PANE_WORKER_COMMAND,
+ String(ordinal),
+ at,
+ ];
+ },
+ })();
+
+ yield* spawn(function* () {
+ while (!(yield* exists(`${room}/shell-pid`))) {
+ yield* sleep(15);
+ }
+ while (tmux.clients.length === 0) {
+ yield* sleep(15);
+ }
+ yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`);
+ });
+
+ const execution = yield* execute({
+ path: path.join(room, "doc.md"),
+ stream: new InMemoryStream(),
+ includes: [room],
+ });
+ const subscription = yield* execution.output;
+ let next = yield* subscription.next();
+ while (!next.done) {
+ output = next.value;
+ next = yield* subscription.next();
+ }
+ outcome = yield* execution;
+ });
+
+ expect(outcome?.ok).toBe(false);
+ const refusal = outcome?.ok === false ? String(outcome.error) : "";
+ expect(refusal).toContain("terminal server");
+ // Nothing private in it, and nothing after the grid ran.
+ expect(refusal).not.toContain(room);
+ expect(output).not.toContain("AFTER_THE_GRID");
+ });
+
+ it("TH1: without a terminal, a grid refuses before anything exists", function* () {
+ const before = yield* until(readdir(tmpdir()));
+ let refusal = "";
+ try {
+ yield* scoped(function* () {
+ yield* installDenoTerminalProcesses();
+ yield* useProbedProvider({ isTerminal: () => false });
+ });
+ } catch (error) {
+ refusal = error instanceof Error ? error.message : String(error);
+ }
+
+ expect(refusal).toContain("cannot open a grid");
+ expect(refusal).toContain("no terminal");
+ // Before a directory, a socket, a token, a worker, a server or a pane: the
+ // host left nothing behind for having tried.
+ const after = yield* until(readdir(tmpdir()));
+ expect(after.filter((name) => name.startsWith("xmd-grid-")).length).toBe(
+ before.filter((name) => name.startsWith("xmd-grid-")).length,
+ );
+ });
+
+ it("TH2: without a usable tmux, a grid refuses the same way", function* () {
+ let refusal = "";
+ try {
+ yield* scoped(function* () {
+ yield* installDenoTerminalProcesses();
+ yield* useProbedProvider({
+ isTerminal: () => true,
+ // A tmux far too old for an explicit layout string.
+ version: "tmux 1.8",
+ });
+ });
+ } catch (error) {
+ refusal = error instanceof Error ? error.message : String(error);
+ }
+ expect(refusal).toContain("cannot open a grid");
+ expect(refusal).toContain("older than tmux");
+ });
+
+ it("TH4: the installed SIGHUP listener cancels the run and tears the grid down", function* () {
+ const room = yield* useScratch();
+ const shell = yield* useShellFixture(room);
+ const script = yield* useScript();
+ const invocation = cliCommand([]);
+ const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true });
+ yield* ensure(() => {
+ tmux.stopPanes();
+ });
+ yield* writeTextFile(
+ path.join(room, "doc.md"),
+ ["", '', "", "", "AFTER_THE_GRID", ""].join(
+ "\n",
+ ),
+ );
+ // The run's foreground lease, which a grid takes before any provider.
+ yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) });
+
+ const sighupBefore = foregroundSignalListeners("SIGHUP");
+ let directory = "";
+ let installed = 0;
+ let outcome: Result | undefined;
+ let output = "";
+ yield* scoped(function* () {
+ yield* foregroundGrid({
+ isTerminal: () => true,
+ createTmux: () => tmux,
+ env: { PATH: "/usr/bin:/bin", SHELL: shell },
+ // deno-lint-ignore require-yield
+ *askVersion() {
+ return { code: 0, stdout: "tmux 3.6a" };
+ },
+ workerCommand: function* (ordinal, at) {
+ directory = at;
+ return [
+ invocation.command,
+ ...invocation.arguments,
+ PANE_WORKER_COMMAND,
+ String(ordinal),
+ at,
+ ];
+ },
+ })();
+ // The listener is the installer's, and this row uses that one.
+ installed = foregroundSignalListeners("SIGHUP");
+
+ yield* spawn(function* () {
+ // Driven by the pane child's own start: the worker spawned, its channel
+ // authenticated, and the shell it launched said so.
+ while (!(yield* exists(`${room}/shell-pid`))) {
+ yield* sleep(15);
+ }
+ process.kill(process.pid, "SIGHUP");
+ });
+
+ const execution = yield* execute({
+ path: path.join(room, "doc.md"),
+ stream: new InMemoryStream(),
+ includes: [room],
+ });
+ const subscription = yield* execution.output;
+ let next = yield* subscription.next();
+ while (!next.done) {
+ output = next.value;
+ next = yield* subscription.next();
+ }
+ outcome = yield* execution;
+ });
+
+ // The installer put its listener on, and took it off with the run.
+ expect(installed).toBe(sighupBefore + 1);
+ expect(foregroundSignalListeners("SIGHUP")).toBe(sighupBefore);
+
+ // Cancellation, not a reader close: the run failed and nothing after the
+ // grid ran in that attempt.
+ expect(outcome?.ok).toBe(false);
+ expect(output).not.toContain("AFTER_THE_GRID");
+
+ // Every teardown phase completed before the result was observed. The pane's
+ // child is gone, the worker is gone, the server is gone, and the private
+ // directory — which is removed last, after its sockets have closed — is
+ // gone with them.
+ const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim());
+ expect(shellPid).toBeGreaterThan(0);
+ yield* installDenoTerminalProcesses();
+ expect(yield* processReachable(shellPid)).toBe(false);
+ // Awaited on each process's own exit event, not sampled: a worker that had
+ // not quite gone yet would make a sampled check pass or fail by timing.
+ for (const child of tmux.started) {
+ yield* exited(child);
+ }
+ expect(tmux.alive()).toBe(false);
+ expect(directory).not.toBe("");
+ expect(yield* exists(directory)).toBe(false);
+ });
+
+ it("TH5: an ordinary run shows the grid, and the reader's detach ends it", function* () {
+ // The same host, the same document and the same live grid as TH4. What
+ // differs is the ending: the reader leaves rather than the terminal going
+ // away, so the grid settles and the document carries on — which is the
+ // branch `useHangupCancellation()` has to hand the result back through.
+ const room = yield* useScratch();
+ const shell = yield* useShellFixture(room);
+ const script = yield* useScript();
+ const invocation = cliCommand([]);
+ const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true });
+ yield* ensure(() => {
+ tmux.stopPanes();
+ });
+ yield* writeTextFile(
+ path.join(room, "doc.md"),
+ ["", '', "", "", "AFTER_THE_GRID", ""].join(
+ "\n",
+ ),
+ );
+ yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) });
+
+ let directory = "";
+ let outcome: Result | undefined;
+ let output = "";
+ yield* scoped(function* () {
+ yield* foregroundGrid({
+ isTerminal: () => true,
+ createTmux: () => tmux,
+ env: { PATH: "/usr/bin:/bin", SHELL: shell },
+ // deno-lint-ignore require-yield
+ *askVersion() {
+ return { code: 0, stdout: "tmux 3.6a" };
+ },
+ workerCommand: function* (ordinal, at) {
+ directory = at;
+ return [
+ invocation.command,
+ ...invocation.arguments,
+ PANE_WORKER_COMMAND,
+ String(ordinal),
+ at,
+ ];
+ },
+ })();
+
+ yield* spawn(function* () {
+ // Driven by the grid's own progress: the pane child started, and the
+ // server has a reader's client to report the detach of. No SIGHUP.
+ while (!(yield* exists(`${room}/shell-pid`))) {
+ yield* sleep(15);
+ }
+ while (tmux.clients.length === 0) {
+ yield* sleep(15);
+ }
+ yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`);
+ });
+
+ const execution = yield* execute({
+ path: path.join(room, "doc.md"),
+ stream: new InMemoryStream(),
+ includes: [room],
+ });
+ const subscription = yield* execution.output;
+ let next = yield* subscription.next();
+ while (!next.done) {
+ output = next.value;
+ next = yield* subscription.next();
+ }
+ outcome = yield* execution;
+ });
+
+ // The exact result, handed back through the hangup wrapper rather than
+ // swallowed by it: a handler that answered with nothing would be refused
+ // for having returned before the document produced a result.
+ expect(outcome).toEqual(Ok("\n\nAFTER_THE_GRID\n"));
+ // The reader closed the grid; the document went on.
+ expect(output).toContain("AFTER_THE_GRID");
+
+ // And it went on over a grid that had actually been taken down: the pane's
+ // child, the workers, the server and the private directory are all gone.
+ const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim());
+ expect(shellPid).toBeGreaterThan(0);
+ yield* installDenoTerminalProcesses();
+ expect(yield* processReachable(shellPid)).toBe(false);
+ for (const child of tmux.started) {
+ yield* exited(child);
+ }
+ expect(tmux.alive()).toBe(false);
+ expect(directory).not.toBe("");
+ expect(yield* exists(directory)).toBe(false);
+ });
+
+ it("TH6: the Deno and compiled entrypoints present grids; Node and Bun do not", function* () {
+ for (const name of ["deno.ts", "compiled.ts"]) {
+ expect((yield* entrypointSource(name)).includes("foregroundGrid()")).toBe(true);
+ }
+ for (const name of ["node.ts", "bun.ts"]) {
+ // Not a different grid: no grid at all, and therefore the default the
+ // shared entry declares — which is the installation that validates a grid
+ // and presents none.
+ expect((yield* entrypointSource(name)).includes("foregroundGrid")).toBe(false);
+ }
+ expect(yield* entrypointSource("cli.ts")).toContain(
+ "installGrid: GridInstaller = unsupportedGrid",
+ );
+ });
+
+ it("TH7: a pane's child is told the terminal's colour depth without a shell startup", function* () {
+ // The reported defect: an agent launched into a pane was colourless while
+ // the same program run by hand in the grid's Shell pane had colour. By
+ // hand it had colour because an interactive shell sources the reader's
+ // startup files, and theirs export `COLORTERM`. A pane's direct child
+ // sources nothing, so what it knows about the terminal is only what the
+ // host hands it — and `COLORTERM` was not in that list.
+ //
+ // Deliberately without an `env` override, so `paneEnvironment()` is what
+ // builds the environment. The shell here is a plain script: it records what
+ // it was given before doing anything, so nothing a startup file might add
+ // can be mistaken for what the pane provided.
+ const room = yield* useScratch();
+ const shell = yield* useShellFixture(room);
+ const script = yield* useScript();
+ const invocation = cliCommand([]);
+ const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true });
+ yield* ensure(() => {
+ tmux.stopPanes();
+ });
+
+ const hadColor = process.env.COLORTERM;
+ const hadShell = process.env.SHELL;
+ process.env.COLORTERM = "truecolor";
+ process.env.SHELL = shell;
+ yield* ensure(() => {
+ if (hadColor === undefined) {
+ delete process.env.COLORTERM;
+ } else {
+ process.env.COLORTERM = hadColor;
+ }
+ if (hadShell === undefined) {
+ delete process.env.SHELL;
+ } else {
+ process.env.SHELL = hadShell;
+ }
+ });
+
+ yield* writeTextFile(
+ path.join(room, "doc.md"),
+ ["", '', "", ""].join("\n"),
+ );
+ yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) });
+
+ yield* scoped(function* () {
+ yield* foregroundGrid({
+ isTerminal: () => true,
+ createTmux: () => tmux,
+ // deno-lint-ignore require-yield
+ *askVersion() {
+ return { code: 0, stdout: "tmux 3.6a" };
+ },
+ workerCommand: function* (ordinal, at) {
+ return [
+ invocation.command,
+ ...invocation.arguments,
+ PANE_WORKER_COMMAND,
+ String(ordinal),
+ at,
+ ];
+ },
+ })();
+
+ yield* spawn(function* () {
+ while (!(yield* exists(`${room}/shell-pid`))) {
+ yield* sleep(15);
+ }
+ while (tmux.clients.length === 0) {
+ yield* sleep(15);
+ }
+ yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`);
+ });
+
+ const execution = yield* execute({
+ path: path.join(room, "doc.md"),
+ stream: new InMemoryStream(),
+ includes: [room],
+ });
+ const subscription = yield* execution.output;
+ let next = yield* subscription.next();
+ while (!next.done) {
+ next = yield* subscription.next();
+ }
+ yield* execution;
+ });
+
+ const given = yield* readTextFile(`${room}/shell-env`);
+ // What the terminal is, and how much of it the child may use.
+ expect(given).toContain("TERM=");
+ expect(given).toContain("COLORTERM=truecolor");
+ });
+
+ it("TH3: a host that installs no provider still validates the grid", function* () {
+ // Node and Bun: the same language and the same validation, and core's own
+ // refusal rather than a provider that half-works.
+ yield* unsupportedGrid();
+ let refusal = "";
+ try {
+ yield* Grids.operations.open({
+ columns: 1,
+ rows: 1,
+ panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }],
+ });
+ } catch (error) {
+ refusal = error instanceof Error ? error.message : String(error);
+ }
+ expect(refusal).toContain("no grid provider is installed");
+ });
+});
diff --git a/packages/cli/tests/run-composition-deno.test.ts b/packages/cli/tests/run-composition-deno.test.ts
index 5d512e000..4ff8190b8 100644
--- a/packages/cli/tests/run-composition-deno.test.ts
+++ b/packages/cli/tests/run-composition-deno.test.ts
@@ -21,7 +21,8 @@ import { exists, readTextFile } from "@effectionx/fs";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import process from "node:process";
-import { API, NativeLauncher, useHostFiles } from "@executablemd/runtime";
+import { API, useHostFiles } from "@executablemd/runtime";
+import { NativeLauncher } from "@executablemd/grid";
import { InMemoryStream } from "@executablemd/durable-streams";
import {
Agent,
diff --git a/packages/cli/tests/session-launch-cli.test.ts b/packages/cli/tests/session-launch-cli.test.ts
index 5909b4f2b..c1ce66292 100644
--- a/packages/cli/tests/session-launch-cli.test.ts
+++ b/packages/cli/tests/session-launch-cli.test.ts
@@ -121,6 +121,20 @@ const ROLES = [
"",
].join("\n");
+/** One authored grid, whose pane content must never run without a provider. */
+const GRID = [
+ "",
+ '',
+ "PANE_MARKER",
+ "",
+ '',
+ "",
+ "",
+].join("\n");
+
+/** A grid the grammar refuses, wherever it is written. */
+const BAD_GRID = ["", '', "", ""].join("\n");
+
const NO_LAUNCH = "PLAIN_MARKER\n\nThis document launches nothing.\n";
describe(
@@ -183,6 +197,55 @@ describe(
expect(result.stdout).toContain("PLAIN_MARKER");
});
+ it("CL6: a piped run refuses a grid before any pane starts", function* () {
+ // `xmd run` under a pipe has no terminal to divide. The grid takes the
+ // run's foreground lease before it contacts a provider, so it is refused
+ // there — before a private directory, a socket, a token, a worker, a
+ // server or a pane exists.
+ //
+ // The wording is the foreground launcher's, and it names
+ // `` even though this document writes none. Recorded as
+ // it is rather than asserted around: it is the diagnostic a reader
+ // actually gets.
+ const result = yield* useFixture({ "grid.md": GRID }, function* (fixture) {
+ return yield* runCli(["run", "grid.md", "--raw"], env(fixture)).join();
+ });
+
+ expect(result.code).toBe(1);
+ const reported = `${result.stdout}${result.stderr}`;
+ // Refused at the foreground lease, which a grid takes before it contacts
+ // any provider — so this run stopped earlier than the tmux prerequisites,
+ // and earlier still than a pane.
+ expect(reported).toContain("needs a terminal");
+ // The pane's own content never ran.
+ expect(reported).not.toContain("PANE_MARKER");
+ // And nothing tmux-shaped reaches the reader.
+ for (const leak of ["tmux -", "socket", "%0", "kill-server"]) {
+ expect(`${leak}: ${reported.includes(leak)}`).toBe(`${leak}: false`);
+ }
+ });
+
+ it("CL7: the syntax is still catalogued where no grid can open", function* () {
+ // Node and Bun keep the language and the validation. A grid whose layout
+ // is wrong is refused as a *grammar* failure wherever it is written, and
+ // that refusal is not the provider's.
+ const result = yield* useFixture({ "bad.md": BAD_GRID }, function* (fixture) {
+ return yield* runCli(["run", "bad.md", "--raw"], env(fixture)).join();
+ });
+
+ expect(result.code).toBe(1);
+ const reported = `${result.stdout}${result.stderr}`;
+ // The concrete structural refusal, named and located — not merely the
+ // absence of a provider message, which an unrelated failure would also
+ // satisfy.
+ expect(reported).toContain(' requires a "columns" prop');
+ expect(reported).toContain("bad.md:1:1");
+ // And it is the grammar's refusal, reached wherever the document is read
+ // rather than at a provider.
+ expect(reported).not.toContain("cannot open a grid");
+ expect(reported).not.toContain("no grid provider is installed");
+ });
+
it("CL5: no behavior is keyed to the filename", function* () {
const result = yield* useFixture({ "roles/team.md": ROLES }, function* (fixture) {
return yield* runCli(["run", "roles/team.md#Architect", "--raw"], env(fixture)).join();
diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts
index 24a997908..80dba87a9 100644
--- a/packages/cli/tests/syntax-cli.test.ts
+++ b/packages/cli/tests/syntax-cli.test.ts
@@ -301,6 +301,33 @@ describe("Tier SX — the run profile the command describes", () => {
]);
});
+ it("TG3: describes both grid constructs without probing for a terminal", function* () {
+ // Whatever this runtime can or cannot open, the language is the same, so
+ // the one boundary a capability probe would cross is a trap here.
+ const catalog = yield* scoped(function* () {
+ yield* API.Process.around({
+ // deno-lint-ignore require-yield
+ *exec([options]): Operation {
+ throw new Error(`describing the syntax ran ${JSON.stringify(options.command)}`);
+ },
+ });
+ return yield* syntaxSymbols([]);
+ });
+ const [structural, builtIn] = catalog.categories;
+
+ const grid = structural.entries.find((entry) => entry.name === "Grid");
+ const pane = structural.entries.find((entry) => entry.name === "Pane");
+ expect(grid?.origin).toEqual({ kind: "structural", construct: "Grid" });
+ expect(pane?.origin).toEqual({ kind: "structural", construct: "Pane" });
+ expect(grid?.syntax).toEqual(["…"]);
+ expect(pane?.syntax).toEqual(['…', '']);
+ expect(grid?.description ?? "").not.toBe("");
+ expect(pane?.description ?? "").not.toBe("");
+ // Reserved syntax, so neither name is a component this profile offers.
+ expect(names(builtIn.entries)).not.toContain("Grid");
+ expect(names(builtIn.entries)).not.toContain("Pane");
+ });
+
it("SX3: describes without minting an execution claimant", function* () {
const catalog = yield* syntaxSymbols([]);
const session = catalog.categories[1].entries.find((entry) => entry.name === "Session");
@@ -539,6 +566,22 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources
});
});
+ it("TG3: prints both grid constructs, in markdown and in JSON", function* () {
+ yield* useWorkspace(WORKSPACE, function* (cwd) {
+ const markdown = yield* runCli(["syntax"], { cwd }).expect();
+ expect(markdown.stdout).toContain("### ``");
+ expect(markdown.stdout).toContain("### ``");
+ expect(markdown.stdout).toContain("…");
+ expect(markdown.stdout).toContain('…');
+ expect(markdown.stdout).toContain('');
+
+ const json = yield* runCli(["syntax", "--json"], { cwd }).expect();
+ const structural = parseSymbols(json.stdout).categories[0].entries;
+ expect(names(structural)).toContain("Grid");
+ expect(names(structural)).toContain("Pane");
+ });
+ });
+
it("SX12: succeeds with the defaults in a package tree full of directory links", function* () {
yield* useWorkspace(
{
diff --git a/packages/core/mod.ts b/packages/core/mod.ts
index 3567808e5..4bac8e2c1 100644
--- a/packages/core/mod.ts
+++ b/packages/core/mod.ts
@@ -152,6 +152,12 @@ export { DocumentOutput } from "./src/api.ts";
export type { DocumentOutputApi } from "./src/api.ts";
export { useNormalizedOutput } from "./src/output/normalize.ts";
export { useTerminalOutput } from "./src/output/terminal.ts";
+// The terminal domain is `@executablemd/grid`'s, and a caller names it
+// directly (DEC-016). What core exports here is only what core owns: the
+// profile that composes a grid into an `Execution`, adapting the terminal
+// lifecycle to this engine's journal descriptions and installation.
+export { installGridProfile } from "./src/grid/profile.ts";
+export type { GridProfileOptions } from "./src/grid/profile.ts";
export { execute, Execution } from "./src/execute.ts";
export type {
diff --git a/packages/core/package.json b/packages/core/package.json
index 5962c603a..dd232bd81 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -19,6 +19,7 @@
"@effectionx/stream-helpers": "0.8.3",
"@effectionx/timebox": "0.4.3",
"@executablemd/durable-streams": "workspace:*",
+ "@executablemd/grid": "workspace:*",
"@executablemd/runtime": "workspace:*",
"@secretlint/core": "13.0.4",
"@secretlint/profiler": "13.0.4",
diff --git a/packages/core/src/agent/function-components.ts b/packages/core/src/agent/function-components.ts
index 043d02b93..3a3389e85 100644
--- a/packages/core/src/agent/function-components.ts
+++ b/packages/core/src/agent/function-components.ts
@@ -23,7 +23,8 @@ import { sessionPlacement } from "./session-request.ts";
import type { ComponentInvocation, FunctionComponent } from "../types.ts";
import type { IdentityClaimant } from "../invocation-identity.ts";
-import { cwd, flushOutput, parseDuration, reserveTerminal } from "@executablemd/runtime";
+import { cwd, parseDuration } from "@executablemd/runtime";
+import { flushOutput, reserveTerminal } from "@executablemd/grid";
import type { Json, PropsSchema } from "../types.ts";
import type { Expansion } from "../expansion.ts";
import { Agent } from "./agent-api.ts";
diff --git a/packages/core/src/agent/launch-owner.ts b/packages/core/src/agent/launch-owner.ts
index 7c7e85285..200741679 100644
--- a/packages/core/src/agent/launch-owner.ts
+++ b/packages/core/src/agent/launch-owner.ts
@@ -16,7 +16,8 @@
import { createApi } from "@effectionx/context-api";
import { scoped } from "effection";
import type { Operation, Stream } from "effection";
-import { cwd, flushOutput, reserveTerminal } from "@executablemd/runtime";
+import { cwd } from "@executablemd/runtime";
+import { flushOutput, reserveTerminal } from "@executablemd/grid";
import { Agent, AGENT_API } from "./agent-api.ts";
import type {
AgentApi,
diff --git a/packages/core/src/document-validation.ts b/packages/core/src/document-validation.ts
index 15350db98..d24bad55b 100644
--- a/packages/core/src/document-validation.ts
+++ b/packages/core/src/document-validation.ts
@@ -74,7 +74,9 @@ import {
strayCaseMessage,
strayElseMessage,
strayStructuralMessage,
+ strayPaneMessage,
switchStructure,
+ gridStructure,
} from "./structural-rules.ts";
import type { StructuralViolation } from "./structural-rules.ts";
import type {
@@ -314,6 +316,8 @@ interface LexicalContext {
readonly insideIf: boolean;
/** Whether a `` in this source lexically encloses this point. */
readonly insideSwitch: boolean;
+ /** Whether a `` in this source lexically encloses this point. */
+ readonly insideGrid: boolean;
/** Whether the immediate parent is an ``. */
readonly underAnswers: boolean;
}
@@ -492,6 +496,7 @@ class ValidationState {
insideLoop: false,
insideIf: false,
insideSwitch: false,
+ insideGrid: false,
underAnswers: false,
});
}
@@ -1102,6 +1107,24 @@ class ValidationState {
return context.insideSwitch
? []
: [{ code: "structural-usage-invalid", source: "Case", message: strayCaseMessage() }];
+ case "Grid":
+ // The whole layout is decided from source, so every pane's own mistake
+ // is reported where it was written — and so is a construct written
+ // below the grid that the grid does not lay out.
+ return gridStructure(segment).violations;
+ case "Pane":
+ // A well-placed `` is its grid's, and one placed wrongly
+ // under a grid is already reported by that grid's own structure. What
+ // is left is a pane with no grid above it at all.
+ return context.insideGrid
+ ? []
+ : [
+ {
+ code: "structural-usage-invalid",
+ source: "Pane",
+ message: strayPaneMessage(),
+ },
+ ];
case "Else":
// A well-placed `` is its ``'s, and one placed wrongly under
// an `` is already reported by that ``'s own structure. What is
@@ -1272,6 +1295,7 @@ function childContext(segment: ComponentElement, context: LexicalContext): Lexic
insideLoop: context.insideLoop || segment.name === "Loop",
insideIf: context.insideIf || segment.name === "If",
insideSwitch: context.insideSwitch || segment.name === "Switch",
+ insideGrid: context.insideGrid || segment.name === "Grid",
underAnswers: segment.name === "Answers",
};
}
diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts
index 7e35cf22c..5d8ebb366 100644
--- a/packages/core/src/expand.ts
+++ b/packages/core/src/expand.ts
@@ -13,7 +13,7 @@
* middleware installation) execute before children's code blocks.
*/
-import { ensure, Err, scoped, useScope, withResolvers } from "effection";
+import { ensure, Err, Ok, scoped, useScope, withResolvers } from "effection";
import type { Operation, Result } from "effection";
import type {
FunctionComponent,
@@ -57,9 +57,19 @@ import {
strayCaseMessage,
strayElseMessage,
strayStructuralMessage,
+ strayPaneMessage,
switchStructure,
+ gridColumns,
+ gridColumnsMissingMessage,
+ gridStructure,
+ paneTitle,
+ paneTitleMissingMessage,
} from "./structural-rules.ts";
-import type { StructuralViolation, SwitchCase } from "./structural-rules.ts";
+import type { StructuralViolation, SwitchCase, Pane } from "./structural-rules.ts";
+import { durableGrid, openGrid, gridLayout, toRequest } from "@executablemd/grid/lifecycle";
+import type { PaneWork, PlacedPane } from "@executablemd/grid/lifecycle";
+import { usePaneNativeLauncher, usePaneTerminal } from "@executablemd/grid";
+import { recordGridLayout } from "./grid/journal.ts";
import {
asBindingViolation,
asExpressionViolation,
@@ -135,7 +145,7 @@ import {
import { remark } from "remark";
import { select as cssSelect } from "unist-util-select";
import { toString as mdastToString } from "mdast-util-to-string";
-import { liveEnvironment } from "./live-env.ts";
+import { derivedEnvironment, liveEnvironment } from "./live-env.ts";
import { TestHarnessComponentDefinition } from "./test-harness.ts";
import type { TestHarnessBinding } from "./test-harness.ts";
@@ -1174,6 +1184,35 @@ function* expandListSegments(
break;
}
+ if (segment.name === "Grid") {
+ // No raise() here, like the branches above: expandGrid
+ // reports every error it creates.
+ yield* expandGrid(segment, result, {
+ parentMeta,
+ parentProps,
+ hideSet,
+ path: elementPath,
+ checkedFailures,
+ authority,
+ });
+ break;
+ }
+
+ if (segment.name === "Pane") {
+ // A well-placed is consumed by its and
+ // never expanded on its own. Reaching this branch means the pane sits
+ // outside every grid, so it names no component and is diagnosed
+ // rather than resolved from the filesystem.
+ result.push(
+ yield* raise({
+ type: "error",
+ message: positioned(strayPaneMessage(), segment),
+ source: "Pane",
+ }),
+ );
+ break;
+ }
+
if (segment.name === "Break") {
result.push(...(yield* expandBreak(segment, loop)));
break;
@@ -2029,6 +2068,235 @@ function* expandSwitch(
);
}
+function gridError(segment: ComponentElement, message: string): ErrorSegment {
+ return { type: "error", message: positioned(message, segment), source: "Grid" };
+}
+
+function paneError(segment: ComponentElement, message: string): ErrorSegment {
+ return { type: "error", message: positioned(message, segment), source: "Pane" };
+}
+
+/**
+ * The value one prop of a grid construct produced, or why evaluating
+ * it failed. A missing prop is `undefined`, which is also what an expression
+ * evaluating to `undefined` leaves behind (§6.5) — absence either way, and the
+ * caller says what its construct requires instead.
+ */
+function* resolveStructuralProp(
+ segment: ComponentElement,
+ construct: string,
+ prop: string,
+): Operation> {
+ const expression = segment.expressions[prop];
+ if (expression === undefined) {
+ return Ok(segment.props[prop]);
+ }
+ try {
+ const resolved = yield* resolveExpressionProps(
+ {},
+ { [prop]: expression },
+ construct,
+ segment.projectedEnv,
+ );
+ return Ok(resolved[prop]);
+ } catch (error) {
+ return Err(error instanceof Error ? error : new Error(String(error)));
+ }
+}
+
+/**
+ * Open the grid the author wrote (spec §6.21).
+ *
+ * The whole layout is decided before anything opens: the panes and their forms
+ * from source, then `columns` and each pane's `title` from the values the
+ * document computes. Only once the concrete grid is complete is a terminal
+ * provider anything's business — and this build has none, so the grid refuses
+ * there. Nothing beneath a pane has expanded and no shell has started when it
+ * does, which is what makes the refusal a closed one rather than a partial grid
+ * left behind.
+ */
+/** Everything a pane's own content needs to expand where the grid was written. */
+interface GridSite {
+ readonly parentMeta: Record;
+ readonly parentProps: Record;
+ readonly hideSet: Set;
+ readonly path: string;
+ readonly checkedFailures: CheckedFailures | undefined;
+ readonly authority: ExpansionAuthority | undefined;
+}
+
+function* expandGrid(segment: ComponentElement, owner: Segment[], site: GridSite): Operation {
+ const structure = gridStructure(segment);
+ if (structure.violations.length > 0) {
+ for (const violation of structure.violations) {
+ owner.push(yield* raise(structuralErrorSegment(violation, segment)));
+ }
+ return;
+ }
+
+ const columnsValue = yield* resolveStructuralProp(segment, "Grid", "columns");
+ if (!columnsValue.ok) {
+ owner.push(yield* raise(gridError(segment, columnsValue.error.message)));
+ return;
+ }
+ if (columnsValue.value === undefined) {
+ owner.push(yield* raise(gridError(segment, gridColumnsMissingMessage())));
+ return;
+ }
+ const columns = gridColumns(columnsValue.value);
+ if (!columns.ok) {
+ owner.push(yield* raise(gridError(segment, columns.error.message)));
+ return;
+ }
+
+ const placed: PlacedPane[] = [];
+ for (const pane of structure.panes) {
+ const title = yield* resolvePaneTitle(pane);
+ if (!title.ok) {
+ owner.push(yield* raise(paneError(pane.element, title.error.message)));
+ return;
+ }
+ placed.push({ title: title.value, form: pane.form });
+ }
+
+ const layout = gridLayout(columns.value, placed);
+ // The grid renders nothing into the document: what a pane shows belongs to
+ // that pane, and the sibling after `` renders to the root
+ // again only once the provider has restored it.
+ const identity = {
+ path: site.path,
+ ...(segment.position === undefined ? {} : { position: segment.position }),
+ };
+
+ try {
+ // Recorded in this coroutine, before the lease and before any provider is
+ // contacted: a resumed run whose grid changed is refused while nothing has
+ // been opened. It cannot live inside the grid child, because a completed
+ // child never runs.
+ yield* recordGridLayout(identity, toRequest(layout));
+
+ const retained = yield* durableGrid(function* (boundary) {
+ const work = structure.panes.map((pane, index) =>
+ paneWork(pane, layout.cells[index]!.title, site),
+ );
+ return yield* openGrid(layout, work, boundary);
+ });
+
+ const failed = retained.panes.find((pane) => pane.status === "failed");
+ if (failed !== undefined) {
+ owner.push(yield* raise(gridError(segment, failed.reason)));
+ }
+ } catch (error) {
+ owner.push(
+ yield* raise(gridError(segment, error instanceof Error ? error.message : String(error))),
+ );
+ }
+}
+
+/**
+ * What one authored pane does once the grid has minted its claim.
+ *
+ * A self-closing pane runs the host's default shell through its claim. A paired
+ * pane expands its own content in a scope of its own: it inherits the bindings,
+ * providers, configuration and working directory visible where the grid was
+ * written, and everything it creates afterwards stays inside the pane. Its
+ * `` cannot reach a loop outside the grid, its `` cannot claim an
+ * enclosing body, and a checked failure settles the pane rather than poisoning
+ * the root or a sibling.
+ */
+function paneWork(pane: Pane, title: string, site: GridSite): PaneWork {
+ if (pane.form === "self-closing") {
+ return {
+ ordinal: pane.ordinal,
+ *run(claim, composite) {
+ const outcome = yield* claim.admit(() =>
+ composite.shell(pane.ordinal, () => claim.ready()),
+ );
+ if (outcome.signal !== undefined) {
+ throw new Error(`pane ${pane.ordinal} ("${title}") shell ended on ${outcome.signal}`);
+ }
+ if (outcome.exitCode !== undefined && outcome.exitCode !== 0) {
+ throw new Error(
+ `pane ${pane.ordinal} ("${title}") shell exited with status ${outcome.exitCode}`,
+ );
+ }
+ },
+ };
+ }
+
+ return {
+ ordinal: pane.ordinal,
+ *run(claim, composite) {
+ yield* scoped(function* () {
+ // A pane is not inside the loop the grid was written in, so a
+ // in its content has no loop to exit and says so.
+ yield* ActiveLoop.set(undefined);
+ yield* usePaneTerminal(claim);
+ const shown: Segment[] = [];
+ // What this pane has rendered and not yet shown. A native UI is about
+ // to draw over the pane, so the same rule the root flush follows holds
+ // here: everything the pane has said reaches the reader first.
+ const flushPane = function* (): Operation {
+ const pending = renderSegments(shown);
+ shown.length = 0;
+ if (pending.length > 0) {
+ yield* composite.display(pane.ordinal, pending);
+ }
+ };
+ // A `` written in this pane finds this launcher simply
+ // by being here: it reserves and flushes this pane instead of competing
+ // for the run's one foreground lease, and the child it starts is what
+ // makes this pane ready.
+ yield* usePaneNativeLauncher(claim, flushPane, (request, spawned) =>
+ composite.launch(pane.ordinal, request, spawned),
+ );
+ const siteEnv = yield* env;
+ // Starts from what the grid site can see and keeps its own writes: a
+ // binding this pane makes is visible to later work in this pane and to
+ // nothing else.
+ yield* provideEnv(derivedEnvironment(siteEnv, { ...(siteEnv?.values ?? {}) }));
+
+ yield* expandSegmentsWithin(
+ pane.element.children,
+ site.parentMeta,
+ site.parentProps,
+ site.hideSet,
+ // A counter of its own. Panes expand concurrently, and a shared
+ // mutable counter would hand two of them block identities that depend
+ // on which happened to run first.
+ createBlockCounter(),
+ shown,
+ extendPath(
+ site.path,
+ elementFrame(pane.element.name, elementSite(pane.element.position, pane.index)),
+ ),
+ 0,
+ // The pane's own ledger: a checked failure settles this pane and
+ // cannot reach the root or a sibling.
+ containedLedger(site.checkedFailures),
+ site.authority,
+ // No enclosing value body: a written in a pane cannot claim
+ // one outside the grid.
+ undefined,
+ );
+ yield* flushPane();
+ });
+ },
+ };
+}
+
+/** The label one pane displays, from the value its own `title` prop produced. */
+function* resolvePaneTitle(pane: Pane): Operation> {
+ const value = yield* resolveStructuralProp(pane.element, "Pane", "title");
+ if (!value.ok) {
+ return value;
+ }
+ if (value.value === undefined) {
+ return Err(new Error(paneTitleMissingMessage()));
+ }
+ return paneTitle(value.value);
+}
+
function loopError(segment: ComponentElement, message: string): ErrorSegment {
return { type: "error", message: positioned(message, segment), source: "Loop" };
}
diff --git a/packages/core/src/grid/journal.ts b/packages/core/src/grid/journal.ts
new file mode 100644
index 000000000..0a12af6ac
--- /dev/null
+++ b/packages/core/src/grid/journal.ts
@@ -0,0 +1,207 @@
+/**
+ * Which grid a run opened, and how a resumed run is held to it
+ * (spec §6.21 Durability and replay).
+ *
+ * One entry, appended in the **parent** coroutine and **before** the foreground
+ * lease is taken or any provider is contacted: the columns and rows, and the
+ * ordered pane forms, titles and positions. A resumed run compares what it
+ * derived against what is held and refuses a document whose grid changed while
+ * nothing has been opened and nothing has started.
+ *
+ * It sits in the parent deliberately. The grid itself is a durable child, and a
+ * completed child short-circuits without running — so a comparison written
+ * inside it would never happen on the run that most needs it.
+ *
+ * Provider-neutral throughout. No command, socket, path, process, session,
+ * window or pane identifier, no argv or environment, and no terminal byte is
+ * written here: none of that describes the document, it describes whichever
+ * provider happened to present it, and a resumed run builds a fresh one.
+ */
+
+import type { Operation } from "effection";
+import {
+ createDurableOperation,
+ DurableContext,
+ StaleInputError,
+} from "@executablemd/durable-streams";
+import type { EffectDescription, Json, Workflow } from "@executablemd/durable-streams";
+import type { GridRequest } from "@executablemd/grid";
+
+import { sourceDescription } from "../source-position.ts";
+import type { SourcePosition } from "../types.ts";
+import { retainedLayout } from "@executablemd/grid/lifecycle";
+import type { RetainedGrid } from "@executablemd/grid/lifecycle";
+
+/** A grid's identity within one execution: where it was written. */
+export interface GridIdentity {
+ /** The structural path that reached this element (§5.6). */
+ readonly path: string;
+ readonly position?: Readonly;
+}
+
+type RetainedLayout = RetainedGrid["layout"];
+
+function describe(identity: GridIdentity): EffectDescription {
+ return {
+ type: "terminal_grid_layout",
+ name: `terminal_grid:${identity.path}:layout`,
+ ...sourceDescription(identity.position),
+ };
+}
+
+/** Whether this expansion has a journal to read and append to at all. */
+function* durable(): Operation {
+ return (yield* DurableContext.get()) !== undefined;
+}
+
+/**
+ * Append one entry and return what the entry holds.
+ *
+ * Live it is the value passed in; on replay it is the value the journal already
+ * held, which is the only way a caller tells the two apart.
+ */
+function* append(description: EffectDescription, value: Json): Workflow {
+ return yield createDurableOperation(description, function* () {
+ return value;
+ });
+}
+
+/**
+ * The layout a journal entry holds, parsed member by member.
+ *
+ * Total: every field is read and checked, and anything the record does not say
+ * exactly — a missing member, a member of the wrong kind, an extra one, a pane
+ * whose ordinal is not its position, a row or column that does not follow from
+ * the columns it claims — makes the record unreadable rather than half-read. A
+ * layout is what a resumed run is held to, so a record that cannot be believed
+ * in full must not be believed in part.
+ */
+function readLayout(value: unknown): RetainedLayout | undefined {
+ const record = members(value);
+ if (record === undefined || !onlyNames(record, ["columns", "rows", "panes"])) {
+ return undefined;
+ }
+ const columns = positiveInteger(record.columns);
+ const rows = positiveInteger(record.rows);
+ const list = record.panes;
+ if (columns === undefined || rows === undefined || !Array.isArray(list)) {
+ return undefined;
+ }
+ const panes: RetainedLayout["panes"] = [];
+ for (const [index, entry] of list.entries()) {
+ const pane = readPane(entry, index, columns);
+ if (pane === undefined) {
+ return undefined;
+ }
+ panes.push(pane);
+ }
+ // The rows a grid claims have to be the rows its panes need, or the record
+ // describes a grid nothing could have derived.
+ if (panes.length === 0 || Math.ceil(panes.length / columns) !== rows) {
+ return undefined;
+ }
+ return { columns, rows, panes };
+}
+
+/** One retained pane, checked against the position it claims to occupy. */
+function readPane(
+ value: unknown,
+ index: number,
+ columns: number,
+): RetainedLayout["panes"][number] | undefined {
+ const record = members(value);
+ if (record === undefined || !onlyNames(record, ["ordinal", "title", "form", "row", "column"])) {
+ return undefined;
+ }
+ const { ordinal, title, form, row, column } = record;
+ if (ordinal !== index) {
+ return undefined;
+ }
+ if (typeof title !== "string" || title.length === 0) {
+ return undefined;
+ }
+ if (form !== "paired" && form !== "self-closing") {
+ return undefined;
+ }
+ // Derived, not asserted: a position that does not follow from the ordinal and
+ // the column count is a record that disagrees with itself.
+ if (row !== Math.floor(index / columns) || column !== index % columns) {
+ return undefined;
+ }
+ return { ordinal, title, form, row, column };
+}
+
+/** The members of a JSON object, or `undefined` for anything else. */
+function members(value: unknown): Record | undefined {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ return undefined;
+ }
+ return Object.fromEntries(Object.entries(value));
+}
+
+/** Whether a record carries exactly these member names, and no others. */
+function onlyNames(record: Record, names: readonly string[]): boolean {
+ const present = Object.keys(record);
+ return present.length === names.length && names.every((name) => name in record);
+}
+
+function positiveInteger(value: unknown): number | undefined {
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
+}
+
+/** How two layouts differ, in the words an author can act on. */
+function divergence(held: RetainedLayout, derived: RetainedLayout): string | undefined {
+ if (held.columns !== derived.columns) {
+ return `columns ${held.columns} rather than ${derived.columns}`;
+ }
+ if (held.panes.length !== derived.panes.length) {
+ return `${held.panes.length} panes rather than ${derived.panes.length}`;
+ }
+ for (const [index, pane] of derived.panes.entries()) {
+ const before = held.panes[index]!;
+ if (before.title !== pane.title) {
+ return `pane ${index} titled "${before.title}" rather than "${pane.title}"`;
+ }
+ if (before.form !== pane.form) {
+ return `pane ${index} written ${before.form} rather than ${pane.form}`;
+ }
+ if (before.row !== pane.row || before.column !== pane.column) {
+ return (
+ `pane ${index} at row ${before.row}, column ${before.column} rather than row ` +
+ `${pane.row}, column ${pane.column}`
+ );
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Record which grid this is, and refuse a resumed run whose grid changed.
+ *
+ * Expansion driven without a journal records nothing and behaves identically.
+ */
+export function* recordGridLayout(identity: GridIdentity, request: GridRequest): Operation {
+ if (!(yield* durable())) {
+ return;
+ }
+ const derived = retainedLayout(request);
+ const description = describe(identity);
+ const stored = yield* append(description, derived);
+ const held = readLayout(stored);
+ if (held === undefined) {
+ throw new StaleInputError(
+ `The journal's record of "${description.name}" is not a grid layout. Re-run the ` +
+ "document from the start rather than resuming from this journal.",
+ { coroutineId: identity.path, description },
+ );
+ }
+ const changed = divergence(held, derived);
+ if (changed !== undefined) {
+ throw new StaleInputError(
+ `The journal records this grid as a grid with ${changed}. A grid whose layout ` +
+ "changed cannot be replayed onto this run. Re-run the document from the start rather " +
+ "than resuming from this journal.",
+ { coroutineId: identity.path, description },
+ );
+ }
+}
diff --git a/packages/core/src/grid/profile.ts b/packages/core/src/grid/profile.ts
new file mode 100644
index 000000000..106481b8a
--- /dev/null
+++ b/packages/core/src/grid/profile.ts
@@ -0,0 +1,57 @@
+/**
+ * Opening one grid installation for a live document.
+ *
+ * A grid needs two things before it can be durable at all: this execution's
+ * installation — which owns the generation every request belongs to and the
+ * registry of the grids it issued — and a provider installed against the
+ * authority that installation mints. A grid outside one refuses rather than
+ * presenting something no replay could resume.
+ *
+ * The installation's lifetime has to surround authored work and end while the
+ * journal is still live, which is what `Execution.document` is.
+ */
+
+import { scoped } from "effection";
+import type { Operation } from "effection";
+
+import { Execution } from "../execute.ts";
+import { installGridProvider, useGridInstallation } from "@executablemd/grid/lifecycle";
+
+export interface GridProfileOptions {
+ /**
+ * The registered provider to install for this execution.
+ *
+ * Omitted, the installation is opened and no provider is installed — which is
+ * a host that validates and inspects grids but cannot present one, and
+ * refuses when a document asks for one.
+ */
+ readonly provider?: string;
+ /** How the provider names itself in provider-neutral diagnostics. */
+ readonly label?: string;
+}
+
+/**
+ * Install the grid profile for the executions composed under it.
+ *
+ * The authority reaches the named provider's factory and nothing else: it is
+ * delivered through the installation handshake rather than published, so a
+ * handler that answers the install request itself installs no provider and the
+ * document is told so.
+ */
+export function installGridProfile(options: GridProfileOptions = {}): Operation {
+ return Execution.around({
+ *document([request], next) {
+ yield* scoped(function* () {
+ const authority = yield* useGridInstallation();
+ if (options.provider !== undefined) {
+ yield* installGridProvider(
+ options.provider,
+ { label: options.label ?? options.provider },
+ authority,
+ );
+ }
+ yield* next(request);
+ });
+ },
+ });
+}
diff --git a/packages/core/src/structural-rules.ts b/packages/core/src/structural-rules.ts
index e343ad872..4a7895ad9 100644
--- a/packages/core/src/structural-rules.ts
+++ b/packages/core/src/structural-rules.ts
@@ -1034,3 +1034,226 @@ export function answerViolations(segment: ComponentElement): StructuralViolation
}
return found;
}
+
+const TERMINAL_GRID_PROPS = new Set(["columns"]);
+const TERMINAL_PROPS = new Set(["title"]);
+
+/** What a `` written outside the grid that lays it out says. */
+export function strayPaneMessage(): string {
+ return (
+ " must be a direct child of . is reserved: it never " +
+ "resolves a component, and only the grid it belongs to can place it."
+ );
+}
+
+/** What a `` written inside another grid says. */
+export function nestedGridMessage(): string {
+ return (
+ " cannot be written inside another . A grid lays out the " +
+ "panes it is written with, so one pane cannot become a grid of its own."
+ );
+}
+
+/**
+ * How many columns a grid lays its panes across, or why `columns` rejects it.
+ *
+ * The same rule wherever the value came from: a literal is checked while the
+ * document is only being read, and an expression's answer is checked here too
+ * once expansion has evaluated it.
+ */
+export function gridColumns(columns: Json): Result {
+ if (typeof columns !== "number") {
+ return Err(
+ new Error(`Prop "columns" on must be a positive integer, not ${jsonKind(columns)}.`),
+ );
+ }
+ if (!Number.isInteger(columns) || columns < 1) {
+ return Err(
+ new Error(
+ `Prop "columns" on must be a positive integer. Got: ` +
+ `${JSON.stringify(columns)}.`,
+ ),
+ );
+ }
+ return Ok(columns);
+}
+
+/** What a `` naming no column count at all says. */
+export function gridColumnsMissingMessage(): string {
+ return ' requires a "columns" prop (a positive integer).';
+}
+
+/** The label one pane displays, or why `title` rejects it. */
+export function paneTitle(title: Json): Result {
+ if (typeof title !== "string") {
+ return Err(
+ new Error(`Prop "title" on must be a non-empty string, not ${jsonKind(title)}.`),
+ );
+ }
+ if (title.length === 0) {
+ return Err(new Error('Prop "title" on must be a non-empty string. Got: "".'));
+ }
+ return Ok(title);
+}
+
+/** What a `` naming no title at all says. */
+export function paneTitleMissingMessage(): string {
+ return ' requires a "title" prop (the label the pane displays).';
+}
+
+/** One pane a grid lays out, and where it sat among its siblings. */
+export interface Pane {
+ readonly element: ComponentElement;
+ /** The child index the pane was written at. */
+ readonly index: number;
+ /**
+ * The pane's structural identity: its position among the grid's panes,
+ * counting from zero. A title is a display label and identifies nothing.
+ */
+ readonly ordinal: number;
+ /** Whether the pane runs the markdown it holds or the host's default shell. */
+ readonly form: "paired" | "self-closing";
+}
+
+/** How a `` body divides into panes, and what the division got wrong. */
+export interface GridStructure {
+ readonly violations: StructuralViolation[];
+ /** The direct panes, in authored order. */
+ readonly panes: Pane[];
+}
+
+/** Which of a pane's two forms was written: its own markdown, or a shell. */
+function paneForm(segment: ComponentElement): Pane["form"] {
+ return segment.selfClosing ? "self-closing" : "paired";
+}
+
+/** Everything one `` pane decides from what the author wrote (spec §6.21). */
+function paneViolations(segment: ComponentElement): StructuralViolation[] {
+ const found: StructuralViolation[] = [];
+ const unknownProp = authoredPropNames(segment).find((name) => !TERMINAL_PROPS.has(name));
+ if (unknownProp !== undefined) {
+ found.push(
+ violation(
+ "structural-usage-invalid",
+ "Pane",
+ ` only accepts a "title" prop. Got: "${unknownProp}".`,
+ segment,
+ ),
+ );
+ }
+
+ if ("title" in segment.props) {
+ const title = paneTitle(segment.props.title);
+ if (!title.ok) {
+ found.push(violation("structural-usage-invalid", "Pane", title.error.message, segment));
+ }
+ } else if (!("title" in segment.expressions)) {
+ found.push(violation("structural-usage-invalid", "Pane", paneTitleMissingMessage(), segment));
+ }
+ return found;
+}
+
+/**
+ * Every `` and `` below a grid that the grid does not
+ * lay out. The walk stops at a nested grid, which is reported where it sits and
+ * owns whatever is written beneath it.
+ */
+function misplacedPaneViolations(children: Segment[]): StructuralViolation[] {
+ const found: StructuralViolation[] = [];
+
+ const walk = (segments: Segment[], depth: number): void => {
+ for (const segment of segments) {
+ if (segment.type !== "component") {
+ continue;
+ }
+ if (segment.name === "Grid") {
+ if (depth > 0) {
+ found.push(violation("structural-usage-invalid", "Grid", nestedGridMessage(), segment));
+ }
+ continue;
+ }
+ if (segment.name === "Pane" && depth > 0) {
+ found.push(violation("structural-usage-invalid", "Pane", strayPaneMessage(), segment));
+ }
+ walk(segment.children, depth + 1);
+ }
+ };
+
+ walk(children, 0);
+ return found;
+}
+
+/**
+ * Divide a `` body into its panes and validate the division
+ * (spec §6.21). Everything here is read from source, so a grid whose layout the
+ * author got wrong is refused before `columns` is evaluated, before a pane's
+ * content expands, and before any grid provider is asked for anything.
+ *
+ * The panes are the grid's direct children and only they: a control structure
+ * that would produce panes as it ran cannot be one, because which panes exist
+ * is what the grid must know before it opens anything.
+ */
+export function gridStructure(segment: ComponentElement): GridStructure {
+ const violations: StructuralViolation[] = [];
+ const panes: Pane[] = [];
+
+ const unknownProp = authoredPropNames(segment).find((name) => !TERMINAL_GRID_PROPS.has(name));
+ if (unknownProp !== undefined) {
+ violations.push(
+ violation(
+ "structural-usage-invalid",
+ "Grid",
+ ` only accepts a "columns" prop. Got: "${unknownProp}".`,
+ ),
+ );
+ }
+ if ("columns" in segment.props) {
+ const columns = gridColumns(segment.props.columns);
+ if (!columns.ok) {
+ violations.push(violation("structural-usage-invalid", "Grid", columns.error.message));
+ }
+ } else if (!("columns" in segment.expressions)) {
+ violations.push(violation("structural-usage-invalid", "Grid", gridColumnsMissingMessage()));
+ }
+ if (segment.selfClosing) {
+ violations.push(
+ violation(
+ "structural-usage-invalid",
+ "Grid",
+ " holds the panes it lays out, so it is written paired: " +
+ '.',
+ ),
+ );
+ }
+
+ let substantive = 0;
+ for (const [index, child] of segment.children.entries()) {
+ if (isBlankText(child)) {
+ continue;
+ }
+ substantive++;
+ if (child.type !== "component" || child.name !== "Pane") {
+ violations.push(
+ violation(
+ "structural-usage-invalid",
+ "Grid",
+ ` holds only panes. Found ${describeSegment(child)} ` +
+ "directly inside it. Write control flow inside a pane instead.",
+ child.type === "component" ? child : undefined,
+ ),
+ );
+ continue;
+ }
+ violations.push(...paneViolations(child));
+ panes.push({ element: child, index, ordinal: panes.length, form: paneForm(child) });
+ }
+
+ if (!segment.selfClosing && substantive === 0) {
+ violations.push(
+ violation("structural-usage-invalid", "Grid", " requires at least one pane."),
+ );
+ }
+
+ violations.push(...misplacedPaneViolations(segment.children));
+ return { violations, panes };
+}
diff --git a/packages/core/src/structural.ts b/packages/core/src/structural.ts
index ad36a80ae..7a7e43989 100644
--- a/packages/core/src/structural.ts
+++ b/packages/core/src/structural.ts
@@ -169,6 +169,25 @@ export const STRUCTURAL_DECLARATIONS: readonly StructuralDeclaration[] = [
as: null,
context: "A multiline template, in place of the single-line `template` prop.",
},
+ {
+ name: "Grid",
+ syntax: ["…"],
+ description:
+ "Open several panes in one view. " +
+ '`…`',
+ as: null,
+ context: "The `` panes the grid lays out.",
+ },
+ {
+ name: "Pane",
+ syntax: ['…', ''],
+ description:
+ "Expand Markdown or open a shell in a pane. " +
+ '`…` runs content; ' +
+ '`` opens a shell.',
+ as: null,
+ context: "Markdown the pane runs, in the paired form.",
+ },
];
/**
diff --git a/packages/core/tests/agent-session-launch.test.ts b/packages/core/tests/agent-session-launch.test.ts
index cc87cbfa1..5dcc38a97 100644
--- a/packages/core/tests/agent-session-launch.test.ts
+++ b/packages/core/tests/agent-session-launch.test.ts
@@ -13,7 +13,7 @@ import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
import { InMemoryStream } from "@executablemd/durable-streams";
import type { DurableEvent } from "@executablemd/durable-streams";
-import { ensure, scoped, spawn, until, withResolvers } from "effection";
+import { ensure, resource, scoped, spawn, until, withResolvers } from "effection";
import type { Operation, Result, WithResolvers } from "effection";
import { ensureDir, rm, writeTextFile } from "@effectionx/fs";
import { createHash, randomUUID } from "node:crypto";
@@ -33,14 +33,23 @@ import { parsePrepared } from "../src/agent/launch-journal.ts";
import type { AgentLaunchRequest } from "../src/agent/launch-request.ts";
import { installAgentComponents } from "../src/agent/components.ts";
import type { AgentProviderFactory } from "../src/agent/provider-api.ts";
+import { API, useHostFiles } from "@executablemd/runtime";
import {
- API,
- installControlledLauncher,
NATIVE_LAUNCHER_UNAVAILABLE,
nativeLaunch,
- useHostFiles,
-} from "@executablemd/runtime";
-import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/runtime";
+ reserveTerminal,
+ Grids,
+} from "@executablemd/grid";
+import {
+ installControlledLauncher,
+ prepareControlledComposite,
+ gridProviderLog,
+} from "@executablemd/grid/test";
+import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/grid";
+import { createGridClaims } from "@executablemd/grid/lifecycle";
+import { usePaneNativeLauncher } from "@executablemd/grid";
+import { installGridProfile } from "../src/grid/profile.ts";
+import { registerGridProvider } from "@executablemd/grid";
import type { Json } from "../src/types.ts";
const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
@@ -216,6 +225,19 @@ interface RunOptions {
next: (request: AgentLaunchRequest) => Operation,
) => Operation;
secretDetection?: boolean;
+ /**
+ * Install a controlled grid provider, so the document can open a grid.
+ *
+ * The reader stays until every pane has settled, so a row about what a pane
+ * launched is not racing the close that would cancel it.
+ */
+ grid?: boolean;
+ /** Start the native child, in place of a runtime that would. */
+ start?: (request: NativeLaunchRequest, spawned: () => void) => Operation;
+ /** Called as the composite shows each pane state, in order. */
+ onPaneState?: (ordinal: number, state: string) => void;
+ /** Called when the composite is shown to the reader. */
+ onAttach?: () => void;
}
interface Run {
@@ -224,6 +246,8 @@ interface Run {
stub: LaunchStub;
launcher: LauncherLog;
events: DurableEvent[];
+ /** Everything the controlled composite did, in order. */
+ composite: string[];
}
function* runDoc(doc: string, options: RunOptions = {}): Operation {
@@ -277,10 +301,68 @@ function* runDoc(doc: string, options: RunOptions = {}): Operation {
})(),
}
: {}),
+ ...(options.start === undefined ? {} : { start: options.start }),
outcome: () => options.outcome ?? { exitCode: 0 },
});
}
+ const providerLog = gridProviderLog();
+ if (options.grid === true) {
+ // The reader leaves once every pane has settled. Leaving sooner is a real
+ // thing a reader does — TG12 owns that — but a row about what a pane
+ // launched must not race the close that cancels it.
+ const settled = withResolvers();
+ let panes = 0;
+ let done = 0;
+ yield* registerGridProvider("controlled", function* (_settings, authority) {
+ yield* Grids.around(
+ {
+ *open([request]) {
+ const composite = yield* prepareControlledComposite(request, {
+ log: providerLog,
+ close: () => settled.operation,
+ // The pane endpoint a paired pane's `` now
+ // reaches. It records what it was asked to start and answers,
+ // exactly as the host launcher used to — so these rows are
+ // about the pane, not about a launcher having moved.
+ *launch(_ordinal, asked, spawned) {
+ launcher.requests.push(asked);
+ launcher.order.push("launch");
+ if (options.start) {
+ yield* options.start(asked, spawned);
+ } else {
+ spawned();
+ }
+ return options.outcome ?? { exitCode: 0 };
+ },
+ // deno-lint-ignore require-yield
+ *onPrepare(asked) {
+ panes = asked.panes.length;
+ },
+ // deno-lint-ignore require-yield
+ *onAttach() {
+ options.onAttach?.();
+ },
+ onUpdate(ordinal, state) {
+ options.onPaneState?.(ordinal, state);
+ if (state === "succeeded" || state === "failed" || state === "closed") {
+ done++;
+ if (done >= panes) {
+ settled.resolve();
+ }
+ }
+ },
+ });
+ yield* authority.present(request, composite);
+ return undefined;
+ },
+ },
+ { at: "min" },
+ );
+ });
+ yield* installGridProfile({ provider: "controlled" });
+ }
+
yield* installAgentComponents({
rootProvider: {
factory: stub.factory,
@@ -317,6 +399,7 @@ function* runDoc(doc: string, options: RunOptions = {}): Operation {
stub,
launcher,
events: yield* stream.readAll(),
+ composite: providerLog.events,
};
});
}
@@ -768,6 +851,226 @@ describe("Tier SL — native session launch", () => {
});
});
+/**
+ * Tier SP — `` inside a terminal pane
+ * (specs/native-agent-session-launch-spec.md §Grid composition).
+ *
+ * The launch is the same launch. Nothing here passes a pane to it, and its
+ * request, result and retained phases are the ones a root launch would have.
+ * What changes is which terminal answers, and these rows are about that: a
+ * pane's own lease instead of the run's, panes that do not contend with each
+ * other, one that is exclusive to itself, and a readiness latch nothing but a
+ * started child can trip.
+ */
+describe("Tier SP — a launch inside a terminal pane", () => {
+ /** Two panes, each launching a session of its own. */
+ const PANES = [
+ "",
+ '',
+ 'left work',
+ "",
+ '',
+ 'right work',
+ "",
+ "",
+ "",
+ ].join("\n");
+
+ it("SP1: a pane launch takes that pane, not the run's foreground lease", function* () {
+ const run = yield* runDoc(PANES, { grid: true });
+
+ expect(run.result.ok ? "" : run.result.error.message).toBe("");
+ // The grid took the one root lease, and the two launches inside it did not
+ // ask for it. Had either delegated, the host launcher would have refused
+ // the second holder and the document would have failed here.
+ expect(run.launcher.reserved).toBe(1);
+ expect(run.launcher.requests.length).toBe(2);
+ // Both went to the provider unchanged: same argv a root launch builds, and
+ // nothing about a pane in it.
+ for (const request of run.launcher.requests) {
+ expect(request.command).toEqual(["stub-ui", "--resume", run.stub.nativeSessionId]);
+ expect(JSON.stringify(request)).not.toContain("pane");
+ expect(JSON.stringify(request)).not.toContain("ordinal");
+ }
+ });
+
+ it("SP2: launches in distinct panes hold their terminals at the same time", function* () {
+ // Each launch waits for the other to have started. Two launches sharing one
+ // lease would serialise, and the first would wait for a second that cannot
+ // begin — so this row hangs rather than passing if they contend.
+ const both = withResolvers();
+ let started = 0;
+ const run = yield* runDoc(PANES, {
+ grid: true,
+ start: function* (_request, spawned) {
+ spawned();
+ started++;
+ if (started === 2) {
+ both.resolve();
+ }
+ yield* both.operation;
+ },
+ });
+
+ expect(run.result.ok ? "" : run.result.error.message).toBe("");
+ expect(started).toBe(2);
+ expect(run.launcher.requests.length).toBe(2);
+ });
+
+ it("SP3: a pane is ready only once its native child has started", function* () {
+ const order: string[] = [];
+ const bothPrepared = withResolvers();
+ let prepared = 0;
+ const run = yield* runDoc(PANES, {
+ grid: true,
+ start: function* (_request, spawned) {
+ // Prepared, reserved, flushed and routed to the provider — and none of
+ // that is a start. Both launches get this far before either child does.
+ order.push("prepare");
+ prepared++;
+ if (prepared === 2) {
+ bothPrepared.resolve();
+ }
+ yield* bothPrepared.operation;
+ order.push("spawn");
+ spawned();
+ },
+ onPaneState: (_ordinal, state) => {
+ if (state === "running") {
+ order.push("running");
+ }
+ },
+ onAttach: () => order.push("attach"),
+ });
+
+ expect(run.result.ok ? "" : run.result.error.message).toBe("");
+ // Neither pane was running, and nothing was shown, while both launches sat
+ // one step short of starting a child.
+ expect(order.slice(0, 4)).toEqual(["prepare", "prepare", "spawn", "spawn"]);
+ expect(order.filter((event) => event === "running").length).toBe(2);
+ expect(order.indexOf("attach")).toBeGreaterThan(order.lastIndexOf("spawn"));
+ });
+
+ it("SP4: a launch that fails before the spawn keeps its phases and shows nothing", function* () {
+ let started = 0;
+ const run = yield* runDoc(PANES, {
+ grid: true,
+ start: function* (_request, spawned) {
+ // One pane's child never starts, and nothing is reported: the readiness
+ // latch belongs to a child that started.
+ started++;
+ if (started === 1) {
+ yield* until(Promise.resolve());
+ throw new Error("the native UI could not be started");
+ }
+ spawned();
+ },
+ });
+
+ expect(run.result.ok).toBe(false);
+ // Nothing was ever shown: a grid whose pane failed to start attaches no
+ // partial composite.
+ expect(run.composite.includes("attach:0")).toBe(false);
+ // And what the launch had already made durable is still there. The grid
+ // does not roll a completed preparation back.
+ expect(retainedPhases(run.events)).toContain("prepared");
+ expect(preparedRecord(run.events).nativeSessionId).toBe(run.stub.nativeSessionId);
+ });
+
+ /**
+ * A lease that outlives the child it protects, and unwinds slowly.
+ *
+ * This is the shape a session acquisition has: the provider takes ownership,
+ * performs the whole launch inside it, and releases it as the launch's scope
+ * comes down — *inside* the terminal reservation, so the pane is still held
+ * while it happens. `entered` says the unwinding has begun; `release` lets it
+ * finish.
+ */
+ function heldLease(entered: WithResolvers, release: WithResolvers): Operation {
+ return resource(function* (provide) {
+ yield* ensure(function* () {
+ entered.resolve();
+ yield* release.operation;
+ });
+ yield* provide();
+ });
+ }
+
+ /** Ask this pane for its terminal, and report the refusal if there is one. */
+ function reserveOnce(): Operation {
+ return (function* (): Operation {
+ try {
+ yield* scoped(() => reserveTerminal());
+ return "admitted";
+ } catch (error) {
+ return error instanceof Error ? error.message : String(error);
+ }
+ })();
+ }
+
+ it("SP5: a pane is held until both the child and the lease around it are done", function* () {
+ const claims = createGridClaims({
+ columns: 1,
+ rows: 1,
+ panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }],
+ });
+ const claim = claims.claims[0]!;
+ const childLive = withResolvers();
+ const childMayExit = withResolvers();
+ const unwinding = withResolvers();
+ const release = withResolvers();
+ const asked: string[] = [];
+
+ yield* scoped(function* () {
+ // The composite's pane endpoint, which is what the pane launcher now
+ // delegates to. It stands in for a provider here, and behaves like one:
+ // it reports the start and answers when the child is done.
+ yield* usePaneNativeLauncher(
+ claim,
+ function* () {},
+ function* (_request, spawned) {
+ spawned();
+ childLive.resolve();
+ yield* childMayExit.operation;
+ return { exitCode: 0 };
+ },
+ );
+
+ const first = yield* spawn(function* () {
+ // The order a launch composes in: this pane, then the lease, then the
+ // child. Which is also the order they come back in, reversed.
+ yield* scoped(function* () {
+ yield* reserveTerminal();
+ yield* heldLease(unwinding, release);
+ yield* nativeLaunch({ command: ["ui"], cwd: "." });
+ });
+ });
+
+ // 1. The native child is live.
+ yield* childLive.operation;
+ asked.push(yield* reserveOnce());
+
+ // 2. The child has gone, but the lease around it is still unwinding —
+ // which is the half a launch that merely returned would never show.
+ childMayExit.resolve();
+ yield* unwinding.operation;
+ asked.push(yield* reserveOnce());
+
+ // 3. Both are done.
+ release.resolve();
+ yield* first;
+ asked.push(yield* reserveOnce());
+ });
+
+ expect(asked.length).toBe(3);
+ expect(asked[0]).toContain("already has a live interactive operation");
+ expect(asked[1]).toContain("already has a live interactive operation");
+ expect(asked[2]).toBe("admitted");
+ // The child that started is what made the pane ready, and it did.
+ expect(claims.readiness[0]?.acknowledged).toBe(true);
+ });
+});
+
/**
* Tier FS — the final public launch surface
* (issue-518-authority-lease-architect-amendment.md §Launch authority).
diff --git a/packages/core/tests/document-validation.test.ts b/packages/core/tests/document-validation.test.ts
index 6c5f6fd91..3c355f8dc 100644
--- a/packages/core/tests/document-validation.test.ts
+++ b/packages/core/tests/document-validation.test.ts
@@ -681,6 +681,153 @@ describe("Tier DV: branch selection", () => {
});
});
+describe("Tier DV: grids", () => {
+ const GRID_DOC = [
+ "",
+ '',
+ '',
+ "",
+ '',
+ "",
+ "",
+ ].join("\n");
+
+ it("TG3: a well-formed grid is valid, and nothing beneath it runs", function* () {
+ const { result, seen } = yield* validateText(GRID_DOC, {
+ tree: { "components/Widget.md": WIDGET },
+ });
+
+ expect(result.outcome).toBe("valid");
+ expect(result.diagnostics).toEqual([]);
+ expect(names(result)).toEqual(["Grid", "Pane", "Widget", "Pane"]);
+ expect(named(result, "Grid").origin).toEqual({
+ kind: "structural",
+ construct: "Grid",
+ });
+ expect(named(result, "Pane").origin).toEqual({
+ kind: "structural",
+ construct: "Pane",
+ });
+ // A pane's body is walked like any other region, and none of it — no
+ // shell, no command, no agent, no terminal — was reached to walk it.
+ expect(seen.effects).toEqual([]);
+ // Reserved means selection never looked for a file that could supply
+ // either construct.
+ expect(seen.reads.some((read) => read.includes("Pane"))).toBe(false);
+ });
+
+ it("TG3: reports each invalid authored form, with no execution", function* () {
+ const invalid: [string, string, string][] = [
+ [
+ "an unknown prop on the grid",
+ '\n',
+ ' only accepts a "columns" prop. Got: "layout".',
+ ],
+ [
+ "a capture on the grid",
+ '\n',
+ ' only accepts a "columns" prop. Got: "as".',
+ ],
+ [
+ "no column count",
+ '\n',
+ ' requires a "columns" prop (a positive integer).',
+ ],
+ [
+ "a column count that is not a positive integer",
+ '\n',
+ 'Prop "columns" on must be a positive integer. Got: 0.',
+ ],
+ [
+ "an unknown prop on a pane",
+ '\n',
+ ' only accepts a "title" prop. Got: "shell".',
+ ],
+ [
+ "no title on a pane",
+ "\n",
+ ' requires a "title" prop (the label the pane displays).',
+ ],
+ [
+ "an empty title",
+ '\n',
+ 'Prop "title" on must be a non-empty string. Got: "".',
+ ],
+ ["a self-closing grid", "\n", " holds the panes it lays out"],
+ [
+ "a grid with no pane",
+ "\n",
+ " requires at least one pane.",
+ ],
+ [
+ "text written directly in a grid",
+ 'a note\n',
+ ' holds only panes. Found text "a note" directly inside it.',
+ ],
+ [
+ "a direct element that is not a pane",
+ '\n',
+ " holds only panes. Found directly inside it.",
+ ],
+ [
+ "a pane produced by control flow",
+ '\n',
+ " holds only panes. Found directly inside it.",
+ ],
+ [
+ "a nested grid",
+ '' +
+ '\n',
+ " cannot be written inside another .",
+ ],
+ [
+ "a pane outside every grid",
+ 'alone\n',
+ " must be a direct child of .",
+ ],
+ [
+ "a pane below a grid that is not one of its panes",
+ '' + "\n",
+ " must be a direct child of .",
+ ],
+ ];
+
+ for (const [form, source, message] of invalid) {
+ const { result, seen } = yield* validateText(source, {
+ tree: { "components/Widget.md": WIDGET },
+ });
+
+ expect(`${form}: ${result.outcome}`).toBe(`${form}: invalid`);
+ expect(`${form}: ${codes(result).includes("structural-usage-invalid")}`).toBe(
+ `${form}: true`,
+ );
+ const said = result.diagnostics.some((diagnostic) => diagnostic.message.includes(message));
+ expect(`${form}: ${said}`).toBe(`${form}: true`);
+ expect(`${form}: ${JSON.stringify(seen.effects)}`).toBe(`${form}: []`);
+ }
+ });
+
+ it("TG3: answers the same way twice", function* () {
+ const first = yield* validateText("\n");
+ const second = yield* validateText("\n");
+
+ expect(JSON.stringify(second.result)).toBe(JSON.stringify(first.result));
+ });
+
+ it("TG3: a dynamic column count and title are decided by expansion, not here", function* () {
+ const { result, seen } = yield* validateText(
+ ["", "", "", ""].join("\n"),
+ );
+
+ // Whether those expressions produce a positive integer and a non-empty
+ // string is a value the document computes, and evaluating one is
+ // expansion's alone.
+ expect(result.outcome).toBe("valid");
+ expect(result.diagnostics).toEqual([]);
+ expect(seen.effects).toEqual([]);
+ });
+});
+
describe("Tier DV: source, target and declaration failures", () => {
const ROWS: {
readonly id: string;
diff --git a/packages/core/tests/grid-structure.test.ts b/packages/core/tests/grid-structure.test.ts
new file mode 100644
index 000000000..0b7f564dc
--- /dev/null
+++ b/packages/core/tests/grid-structure.test.ts
@@ -0,0 +1,356 @@
+/**
+ * Tier TG — the authored structure of a grid (spec §6.21).
+ *
+ * What an author may write, and where each pane lands, decided before anything
+ * opens. These rows drive the real expansion path: a grid the grammar accepts
+ * runs until the point a grid provider would be asked for one, and this
+ * build installs none, so it refuses there and carries the layout it derived
+ * beside the refusal.
+ *
+ * Provider non-observation is asserted rather than assumed. Every run traps the
+ * two boundaries a pane's body would cross — resolving a component and running
+ * a code block — and a row is evidence only when both stayed empty. That the
+ * machine running these tests has no tmux is not evidence of anything: nothing
+ * here would look for one.
+ */
+
+import { describe, it } from "@executablemd/test-support/bdd";
+import { expect } from "@executablemd/test-support/expect";
+import { scoped } from "effection";
+import type { Operation } from "effection";
+
+import { Component } from "../src/component-api.ts";
+import { expandSegments } from "../src/expand.ts";
+import { renderSegments } from "../src/render.ts";
+import { scanSegments } from "../src/scanner.ts";
+import { gridLayout } from "@executablemd/grid/lifecycle";
+import type { Json, Segment } from "../src/types.ts";
+
+interface GridRun {
+ segments: Segment[];
+ output: string;
+ /** Every component the run tried to resolve, in order. */
+ imports: string[];
+ /** The source of every code block the run ran, in order. */
+ blocks: string[];
+ /** Every expression the document evaluated, by label, in order. */
+ calls: string[];
+}
+
+/**
+ * Expand one document with every effect a pane could have trapped.
+ *
+ * A component this run resolves, a code block it runs, or an expression it
+ * evaluates is recorded rather than performed, so "nothing beneath the grid
+ * happened" is something the row reads back instead of assuming.
+ */
+function runGrid(source: string, values: Record = {}): Operation {
+ return scoped(function* () {
+ const imports: string[] = [];
+ const blocks: string[] = [];
+ const calls: string[] = [];
+ yield* Component.around(
+ {
+ // deno-lint-ignore require-yield
+ *importComponent([name], _next) {
+ imports.push(name);
+ throw new Error(`Component not found: ${name}`);
+ },
+ // deno-lint-ignore require-yield
+ *applyModifiers([_modifiers, context], _next) {
+ blocks.push(context.content);
+ return { output: "", exitCode: 0, stderr: "" };
+ },
+ },
+ { at: "min" },
+ );
+ const testEnv = {
+ values: {
+ ...values,
+ seen: (label: string, value: unknown) => {
+ calls.push(label);
+ return value;
+ },
+ },
+ };
+ yield* Component.around({ env: () => testEnv }, { at: "min" });
+ const segments = yield* expandSegments(scanSegments(source), {}, {}, new Set());
+ return { segments, output: renderSegments(segments), imports, blocks, calls };
+ });
+}
+
+function errorMessages(segments: Segment[]): string[] {
+ return segments.filter((segment) => segment.type === "error").map((segment) => segment.message);
+}
+
+/** The one message a run that refused for a single reason reports. */
+function soleError(run: GridRun): string {
+ const messages = errorMessages(run.segments);
+ expect(messages).toHaveLength(1);
+ return messages[0]!;
+}
+
+/**
+ * The grid a run derived, read from the refusal that carries it.
+ *
+ * A run that refused for a grammar or placement reason never derived one, so
+ * asking for it is also how a row states that the grid was complete.
+ */
+function derivedLayout(run: GridRun): Json {
+ const refusal = run.segments.find(
+ (segment) => segment.type === "error" && segment.source === "Grid",
+ );
+ if (refusal === undefined || refusal.type !== "error" || refusal.cause === undefined) {
+ throw new Error(`no grid refusal carrying a layout: ${errorMessages(run.segments)}`);
+ }
+ return refusal.cause;
+}
+
+/** Every boundary a pane's body would have crossed, and none of them did. */
+function reachedNothing(run: GridRun): void {
+ expect(run.imports).toEqual([]);
+ expect(run.blocks).toEqual([]);
+ expect(run.calls).toEqual([]);
+}
+
+/**
+ * Work a pane's body would do, so a body that expanded would be recorded.
+ *
+ * One of each boundary `reachedNothing()` reads: a component to resolve, an
+ * expression to evaluate, and a command to run.
+ */
+const PANE_BODY = [
+ "",
+ "",
+ 'reached',
+ "",
+ "```bash exec",
+ "echo ran",
+ "```",
+].join("\n");
+
+describe("Tier TG — the grid grammar", () => {
+ it("TG1: refuses an unknown prop and `as` on the grid", function* () {
+ const unknown = yield* runGrid('');
+ expect(soleError(unknown)).toContain(' only accepts a "columns" prop. Got: "layout".');
+
+ const captured = yield* runGrid('');
+ expect(soleError(captured)).toContain(' only accepts a "columns" prop. Got: "as".');
+ reachedNothing(unknown);
+ reachedNothing(captured);
+ });
+
+ it("TG1: refuses an unknown prop and `as` on a pane", function* () {
+ const unknown = yield* runGrid('');
+ expect(soleError(unknown)).toContain(' only accepts a "title" prop. Got: "shell".');
+
+ const captured = yield* runGrid('');
+ expect(soleError(captured)).toContain(' only accepts a "title" prop. Got: "as".');
+ reachedNothing(unknown);
+ reachedNothing(captured);
+ });
+
+ it("TG1: requires columns to be a positive integer, however it was written", function* () {
+ const missing = yield* runGrid('');
+ expect(soleError(missing)).toContain(' requires a "columns" prop (a positive integer).');
+
+ for (const literal of ["{0}", "{-1}", "{2.5}", '"2"', "{null}"]) {
+ const run = yield* runGrid(``);
+ expect(soleError(run)).toContain('Prop "columns" on must be a positive');
+ reachedNothing(run);
+ }
+
+ // The same rule reaches a value the document computes, which the source
+ // could not have decided about.
+ const computed = yield* runGrid('', { size: 0 });
+ expect(soleError(computed)).toContain(
+ 'Prop "columns" on must be a positive integer. Got: 0.',
+ );
+ reachedNothing(computed);
+ });
+
+ it("TG1: requires a non-empty title on every pane, however it was written", function* () {
+ const missing = yield* runGrid("");
+ expect(soleError(missing)).toContain(
+ ' requires a "title" prop (the label the pane displays).',
+ );
+
+ for (const literal of ['""', "{3}", "{null}"]) {
+ const run = yield* runGrid(``);
+ expect(soleError(run)).toContain('Prop "title" on must be a non-empty string');
+ reachedNothing(run);
+ }
+
+ const computed = yield* runGrid("", {
+ label: "",
+ });
+ expect(soleError(computed)).toContain(
+ 'Prop "title" on must be a non-empty string. Got: "".',
+ );
+ reachedNothing(computed);
+ });
+
+ it("TG1: refuses a self-closing grid", function* () {
+ const run = yield* runGrid("");
+ expect(soleError(run)).toContain(" holds the panes it lays out");
+ reachedNothing(run);
+ });
+});
+
+describe("Tier TG — structural placement", () => {
+ it("TG2: refuses a grid with no pane", function* () {
+ const run = yield* runGrid("");
+ expect(soleError(run)).toContain(" requires at least one pane.");
+ reachedNothing(run);
+ });
+
+ it("TG2: refuses ordinary text written directly in a grid", function* () {
+ const run = yield* runGrid('a note');
+ expect(soleError(run)).toContain(
+ ' holds only panes. Found text "a note" directly inside it.',
+ );
+ reachedNothing(run);
+ });
+
+ it("TG2: refuses a direct element that is not a pane", function* () {
+ const run = yield* runGrid('');
+ expect(soleError(run)).toContain(
+ " holds only panes. Found directly inside it.",
+ );
+ // The element was refused as authored structure, so it was never resolved.
+ reachedNothing(run);
+ });
+
+ it("TG2: refuses a control structure that would produce the panes", function* () {
+ const run = yield* runGrid(
+ [
+ "",
+ '',
+ '',
+ "",
+ "",
+ ].join("\n"),
+ );
+
+ const messages = errorMessages(run.segments);
+ expect(messages).toHaveLength(2);
+ expect(messages[0]).toContain(" holds only panes. Found directly inside it.");
+ expect(messages[0]).toContain("Write control flow inside a pane instead.");
+ expect(messages[1]).toContain(" must be a direct child of .");
+ // The condition decides which panes would exist, and the grid must know
+ // that from the source, so it is never evaluated.
+ reachedNothing(run);
+ });
+
+ it("TG2: refuses a grid nested inside a pane", function* () {
+ const run = yield* runGrid(
+ [
+ "",
+ '',
+ '',
+ "",
+ "",
+ ].join("\n"),
+ );
+ expect(soleError(run)).toContain(" cannot be written inside another .");
+ reachedNothing(run);
+ });
+
+ it("TG2: refuses a pane written outside every grid", function* () {
+ const alone = yield* runGrid('Instructions.');
+ expect(soleError(alone)).toContain(" must be a direct child of .");
+
+ // Below a grid but not one of its panes is the same mistake, reported where
+ // the pane was written.
+ const buried = yield* runGrid(
+ [
+ "",
+ '',
+ '',
+ "",
+ "",
+ ].join("\n"),
+ );
+ expect(soleError(buried)).toContain(" must be a direct child of .");
+ reachedNothing(alone);
+ reachedNothing(buried);
+ });
+});
+
+describe("Tier TG — row-major layout", () => {
+ const positions = (columns: number, panes: number) =>
+ gridLayout(
+ columns,
+ Array.from({ length: panes }, (_unused, index) => ({
+ title: `pane ${index}`,
+ form: "self-closing" as const,
+ })),
+ ).cells.map((cell) => [cell.row, cell.column]);
+
+ it("TG4: places one through five panes row-major across two columns", function* () {
+ expect(positions(2, 1)).toEqual([[0, 0]]);
+ expect(positions(2, 2)).toEqual([
+ [0, 0],
+ [0, 1],
+ ]);
+ expect(positions(2, 3)).toEqual([
+ [0, 0],
+ [0, 1],
+ [1, 0],
+ ]);
+ expect(positions(2, 4)).toEqual([
+ [0, 0],
+ [0, 1],
+ [1, 0],
+ [1, 1],
+ ]);
+ expect(positions(2, 5)).toEqual([
+ [0, 0],
+ [0, 1],
+ [1, 0],
+ [1, 1],
+ [2, 0],
+ ]);
+ // The last row is left short rather than balanced or padded.
+ expect([1, 2, 3, 4, 5].map((panes) => gridLayout(2, filler(panes)).rows)).toEqual([
+ 1, 1, 2, 2, 3,
+ ]);
+ });
+
+ it("TG4: places one through five panes row-major across three columns", function* () {
+ expect(positions(3, 1)).toEqual([[0, 0]]);
+ expect(positions(3, 2)).toEqual([
+ [0, 0],
+ [0, 1],
+ ]);
+ expect(positions(3, 3)).toEqual([
+ [0, 0],
+ [0, 1],
+ [0, 2],
+ ]);
+ expect(positions(3, 4)).toEqual([
+ [0, 0],
+ [0, 1],
+ [0, 2],
+ [1, 0],
+ ]);
+ expect(positions(3, 5)).toEqual([
+ [0, 0],
+ [0, 1],
+ [0, 2],
+ [1, 0],
+ [1, 1],
+ ]);
+ expect([1, 2, 3, 4, 5].map((panes) => gridLayout(3, filler(panes)).rows)).toEqual([
+ 1, 1, 1, 2, 2,
+ ]);
+ });
+});
+
+/** Panes that differ only in count, for a row about rows. */
+function filler(panes: number): { title: string; form: "self-closing" }[] {
+ return Array.from({ length: panes }, (_unused, index) => ({
+ title: `pane ${index}`,
+ form: "self-closing" as const,
+ }));
+}
diff --git a/packages/core/tests/grid.test.ts b/packages/core/tests/grid.test.ts
new file mode 100644
index 000000000..1e1bd3779
--- /dev/null
+++ b/packages/core/tests/grid.test.ts
@@ -0,0 +1,1862 @@
+/**
+ * Tier TG — running a grid through a replaceable provider
+ * (spec §6.21, architecture.md §Terminal authority, §Atomic presentation and
+ * settlement, §Durability and replay).
+ *
+ * The provider here is controlled and is not tmux: it opens no terminal, starts
+ * no process, and records what it was asked to do in the order it was asked.
+ * Every ordering claim is read off that record. Nothing is inferred from
+ * timing, because a grid that attached too early and one that attached on time
+ * take the same wall clock.
+ *
+ * Readiness is the claim these rows care about most, so it is always driven
+ * explicitly: a pane becomes ready because something called the latch it was
+ * handed, never because it got far enough. That is what lets "started" and "did
+ * some work" be told apart at all.
+ *
+ * A paired pane is ready only when something in it starts and reports a spawn.
+ * Until the native-launch Story lands, `` is what a suite writes
+ * to be that something — and it reaches the pane through the same seam a real
+ * `` will.
+ */
+
+import { describe, it } from "@executablemd/test-support/bdd";
+import { expect } from "@executablemd/test-support/expect";
+import {
+ ensure,
+ race,
+ resource,
+ scoped,
+ sleep,
+ spawn,
+ suspend,
+ until,
+ withResolvers,
+} from "effection";
+import type { Operation, Result, Task } from "effection";
+import { forEach } from "@effectionx/stream-helpers";
+import { rm, writeTextFile } from "@effectionx/fs";
+import { mkdtemp } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { InMemoryStream } from "@executablemd/durable-streams";
+import type { DurableEvent } from "@executablemd/durable-streams";
+import { reserveTerminal, Grids } from "@executablemd/grid";
+import {
+ installControlledLauncher,
+ prepareControlledComposite,
+ gridProviderLog,
+} from "@executablemd/grid/test";
+import type { GridComposite, GridRequest } from "@executablemd/grid";
+import type {
+ ControlledCompositeOptions,
+ GridProviderLog,
+ GridProviderResources,
+} from "@executablemd/grid/test";
+
+import { Component } from "../src/component-api.ts";
+import { execute } from "../src/execute.ts";
+import { registerComponents } from "../src/components/registration.ts";
+import {
+ createGridClaims,
+ GridAuthorityError,
+ useGridInstallation,
+} from "@executablemd/grid/lifecycle";
+import type { GridAuthority } from "@executablemd/grid/lifecycle";
+import { installGridProvider } from "@executablemd/grid/lifecycle";
+import { registerGridProvider, GridProviderInstallError, GridProviders } from "@executablemd/grid";
+import { installGridProfile } from "../src/grid/profile.ts";
+import { paneTerminal } from "@executablemd/grid";
+import type { Json } from "../src/types.ts";
+
+/** One document run against a controlled grid host. */
+interface DocumentRun {
+ outcome: Result;
+ /** Text the consumer received — the root document's own output. */
+ output: string;
+ /** The grid the provider was actually asked to present. */
+ requests: GridRequest[];
+ /** What each pane displayed. */
+ shown: Map;
+ /** Everything the composite did, in order. */
+ events: string[];
+ /** Every mark a tripwire component recorded, in order. */
+ ran: string[];
+ /** Every printed error the run produced, in order. */
+ errors: string[];
+ /** The journal this run read and appended to. */
+ journal: DurableEvent[];
+ /** What the controlled provider still held when the run was over. */
+ live: GridProviderResources;
+}
+
+/**
+ * The mark a document records once it is past the grid.
+ *
+ * It fires whether the grid ran or replayed, so a harness can stop the run at
+ * the same point either way — and a replay that hangs never reaches it, which
+ * is a failure rather than something a deadline would quietly pass.
+ */
+const PAST_THE_GRID = "past the grid";
+
+function useDir(): Operation {
+ return resource(function* (provide) {
+ const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-")));
+ yield* ensure(function* () {
+ yield* rm(dir, { recursive: true, force: true });
+ });
+ yield* provide(dir);
+ });
+}
+
+/** The controlled interactive child, and a tripwire. */
+function useGridComponents(
+ ran: string[],
+ slowMarks: string[] = [],
+ onMark: (mark: string) => void = () => {},
+ afterAttach: () => Operation = function* () {},
+ teardownHeld: () => Operation = function* () {},
+ teardownArmed: () => void = () => {},
+): Operation {
+ return registerComponents([
+ {
+ name: "Interactive",
+ origin: "tier-tg",
+ props: { type: "object", properties: {}, additionalProperties: false },
+ *fn() {
+ const pane = yield* paneTerminal();
+ if (pane === undefined) {
+ throw new Error(" is written inside a pane");
+ }
+ yield* pane.interactive(function* (spawned) {
+ spawned();
+ });
+ return "";
+ },
+ },
+ {
+ name: "Ran",
+ origin: "tier-tg",
+ props: {
+ type: "object",
+ properties: { mark: { type: "string" } },
+ required: ["mark"],
+ additionalProperties: false,
+ },
+ // deno-lint-ignore require-yield
+ *fn(props) {
+ ran.push(String(props.mark));
+ onMark(String(props.mark));
+ return "";
+ },
+ },
+ {
+ // Starts interactively, slowly, and records when it did.
+ name: "Slow",
+ origin: "tier-tg",
+ props: { type: "object", properties: {}, additionalProperties: false },
+ *fn() {
+ const pane = yield* paneTerminal();
+ if (pane === undefined) {
+ throw new Error(" is written inside a pane");
+ }
+ yield* pane.interactive(function* (spawned) {
+ yield* sleep(25);
+ slowMarks.push("ready:slow");
+ spawned();
+ });
+ return "";
+ },
+ },
+ {
+ // Holds the pane open, and blocks its own teardown until released — so a
+ // row can interrupt a run while reader-close teardown is in progress.
+ name: "SlowTeardown",
+ origin: "tier-tg",
+ props: { type: "object", properties: {}, additionalProperties: false },
+ *fn() {
+ yield* ensure(function* () {
+ yield* teardownHeld();
+ });
+ // Armed: the finalizer is installed and this pane is live, which is
+ // what a row waits for before letting the reader leave.
+ teardownArmed();
+ yield* suspend();
+ return "";
+ },
+ },
+ {
+ // Waits until the grid has attached, so a pane can fail *after* the
+ // barrier — which is the failure the grid contains as a status rather
+ // than the startup failure that fails the whole region.
+ name: "AfterAttach",
+ origin: "tier-tg",
+ props: { type: "object", properties: {}, additionalProperties: false },
+ *fn() {
+ yield* afterAttach();
+ return "";
+ },
+ },
+ {
+ name: "Hold",
+ origin: "tier-tg",
+ props: { type: "object", properties: {}, additionalProperties: false },
+ *fn() {
+ yield* suspend();
+ return "";
+ },
+ },
+ ]);
+}
+
+/**
+ * Register a controlled provider that presents through the authority it was
+ * delivered.
+ *
+ * This is the whole handshake in miniature: the factory receives the authority
+ * as an argument, prepares a composite of its own, and presents the exact
+ * request it was routed. Nothing it returns reaches core.
+ */
+function useControlledProvider(
+ options: ControlledCompositeOptions & {
+ /** Present something other than the request that was routed. */
+ readonly substitute?: (request: GridRequest) => GridRequest;
+ /** Answer the routed request without presenting anything at all. */
+ readonly shortCircuit?: boolean;
+ /** Keep the authority for a later, unrouted use. */
+ readonly capture?: (authority: GridAuthority) => void;
+ } = {},
+): Operation {
+ let generation = 0;
+ return registerGridProvider("controlled", function* (_settings, authority) {
+ options.capture?.(authority);
+ yield* Grids.around(
+ {
+ *open([request]) {
+ if (options.shortCircuit === true) {
+ // Answers, presents nothing. Core must not believe this.
+ return { presented: true };
+ }
+ const composite = yield* prepareControlledComposite(request, options, generation++);
+ yield* authority.present(options.substitute?.(request) ?? request, composite);
+ return undefined;
+ },
+ },
+ { at: "min" },
+ );
+ });
+}
+
+/** Everything a controlled grid host installs, for an in-process grid. */
+function useGridHost(
+ options: Parameters[0] = {},
+): Operation {
+ return (function* (): Operation {
+ yield* installControlledLauncher();
+ yield* useControlledProvider(options);
+ const authority = yield* useGridInstallation();
+ yield* installGridProvider("controlled", { label: "controlled" }, authority);
+ return authority;
+ })();
+}
+
+/** Close as soon as the reader is asked, which is the ordinary journey. */
+function immediateClose(): () => Operation {
+ // deno-lint-ignore require-yield
+ return function* () {};
+}
+
+/**
+ * Expand one document against a controlled grid host.
+ *
+ * `provider: false` registers nothing, which is how "a host that cannot open a
+ * grid refuses" is asked for.
+ */
+function runDocument(
+ dir: string,
+ source: string,
+ options: {
+ provider?: boolean;
+ stream?: InMemoryStream;
+ composite?: ControlledCompositeOptions;
+ /** Where `` records that it started. */
+ slowMarks?: string[];
+ /** Props this run supplies. Props are not restored across a continuation. */
+ props?: Record;
+ } = {},
+): Operation {
+ return scoped(function* () {
+ const path = join(dir, "doc.md");
+ yield* writeTextFile(path, source);
+ const requests: GridRequest[] = [];
+ const log = gridProviderLog();
+ const ran: string[] = [];
+ const errors: string[] = [];
+ yield* Component.around({
+ *raise([segment], next) {
+ errors.push(segment.message);
+ return yield* next(segment);
+ },
+ });
+ yield* useGridComponents(ran, options.slowMarks ?? []);
+ yield* installControlledLauncher();
+
+ // The reader stays until every pane has settled. Leaving sooner is a real
+ // thing a reader does — TG12 covers it — but a row about what a pane
+ // rendered must not race the close that cancels it.
+ const settled = withResolvers();
+ let expected = 0;
+ let done = 0;
+ const supplied = options.composite ?? {};
+ if (options.provider !== false) {
+ yield* useControlledProvider({
+ ...supplied,
+ log,
+ close: supplied.close ?? (() => settled.operation),
+ *onPrepare(asked) {
+ expected = asked.panes.length;
+ requests.push(asked);
+ if (supplied.onPrepare) {
+ yield* supplied.onPrepare(asked);
+ }
+ },
+ onUpdate(ordinal, state) {
+ supplied.onUpdate?.(ordinal, state);
+ if (state === "succeeded" || state === "failed" || state === "closed") {
+ done++;
+ if (done >= expected) {
+ settled.resolve();
+ }
+ }
+ },
+ });
+ }
+ yield* installGridProfile(options.provider === false ? {} : { provider: "controlled" });
+
+ const stream = options.stream ?? new InMemoryStream();
+ const execution = yield* execute({
+ path,
+ stream,
+ includes: [dir],
+ ...(options.props === undefined ? {} : { props: options.props }),
+ });
+ const outcome = yield* execution;
+ const output = yield* forEach(function* (_chunk: string) {}, execution.output);
+ return {
+ outcome,
+ output,
+ requests,
+ shown: log.shown,
+ events: log.events,
+ ran,
+ errors,
+ journal: yield* stream.readAll(),
+ live: log.live,
+ };
+ });
+}
+
+/** The message a run failed with, failing the test if it completed. */
+function failureOf(run: DocumentRun): string {
+ if (run.outcome.ok) {
+ throw new Error(`expected the document to fail, but it completed: ${run.outcome.value}`);
+ }
+ return run.outcome.error.message;
+}
+
+/** A grid on its own, which a resumed run can carry to an outcome. */
+function plainDocument(columns: number, panes: string[]): string {
+ return [`