Skip to content

♻️ Extract terminal providers into workspace packages (#717) - #771

Open
taras wants to merge 47 commits into
agent/issue-732-tmux-gridfrom
agent/issue-717-terminal-packages
Open

♻️ Extract terminal providers into workspace packages (#717)#771
taras wants to merge 47 commits into
agent/issue-732-tmux-gridfrom
agent/issue-717-terminal-packages

Conversation

@taras

@taras taras commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Final infrastructure layer under Quest #717, stacked on #747. It includes the accepted #781 rename merged through PR #797.

Why

The grid lifecycle and tmux provider were first implemented inside runtime, core, and CLI under terminal-specific names. That prevented another presentation provider from reusing the neutral contract cleanly and made every pane sound like a physical terminal, including future read-only session views.

What changes

Before:

  • the public syntax was <Terminal.Grid> and <Terminal>;
  • provider-neutral lifecycle and tmux mechanics lived in their first consumers; and
  • the proposed package names made terminal the identity of the presentation.

After:

<Grid columns={2}>
  <Pane title="Implementor">…</Pane>
  <Pane title="Workspace" />
</Grid>
  • @executablemd/grid owns the provider-neutral domain, authority, lifecycle, pane launch routing, terminal capability, process observation, POSIX adapter, and controlled test facets.
  • @executablemd/grid-tmux owns tmux probing, commands, layout, authenticated channels, workers, attach, and ordered teardown.
  • Core retains Markdown structure, expansion, journal descriptions, and profile composition.
  • CLI retains host and entrypoint assembly.

The former packages, implementation paths, exports, component names, and compatibility aliases are removed because this stack has not shipped.

Dependency direction

@executablemd/grid-tmux ──> @executablemd/grid
@executablemd/core      ──> @executablemd/grid
@executablemd/cli       ──> core + runtime + grid + grid-tmux

The neutral package imports neither runtime, core, CLI, nor tmux. The tmux package imports neither runtime, core, nor CLI. Deno and compiled hosts install POSIX observation and tmux; Node and Bun install neither.

Compatibility and preserved behavior

  • Durable event kind terminal_grid_layout and identity terminal_grid:${path}:layout remain byte-identical.
  • Provider identity tmux, the authenticated worker protocol, and hidden terminal-worker invocation remain unchanged.
  • Native argv/cwd/environment delivery, readiness, concurrency, replay, cancellation, quiescence, and teardown retain the accepted Describe terminal grids as executable document structure #729Open terminal grids with tmux in foreground runs #732 behavior.
  • Terminal remains the correct term for PTYs, foreground leases, process observation, signals, and interactive child ownership.
  • Grids remain an xmd run facility and are not added to Workflow.

Review and evidence

Start with packages/grid/tests/package-boundary.test.ts, then inspect the five @executablemd/grid public facets, @executablemd/grid-tmux, core's grid adapters, and the generated publication graph.

Focused entrypoints include:

deno task test packages/grid/tests/package-boundary.test.ts
deno task test packages/core/tests/grid-structure.test.ts packages/core/tests/grid.test.ts
deno task test packages/grid/tests/grid-provider.test.ts packages/grid-tmux/tests/grid-tmux.test.ts
deno task test packages/test-agent/tests/grid-native-launch.test.ts packages/cli/tests/grid-host.test.ts
deno task check
deno task check:jsr

TG21 proves exact public roots, unique descriptors and public errors, dependency direction, canonical imports, publication metadata, rejected-name absence, and survival of the technical terminal capability. The lifecycle, native-launch, host, and tmux suites preserve the earlier stack's behavior.

Scope

Included: package extraction, canonical Grid/Pane naming, every repository consumer, public roots, locks, generated publication order, host composition, and the focused boundary evidence.

Excluded: new grid behavior, Workflow integration, session viewing, a REPL, another multiplexer, and changes to Agent-session identity.

PR #764 remains the separate Codex native-launch certification stacked above this completed #717 infrastructure.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 3 redundant comments. Inline suggestions to remove them below.

// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// and nothing else, which is exactly the difference this closes.

// child inherits this process's, so a pane collapsing it to `{}` started
// the program with no environment whatsoever — no `TERM`, so no colour, and
// no `PATH` or `HOME` either. An environment that *is* supplied crosses
// exactly, gaining nothing ambient.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// exactly, gaining nothing ambient.

// tmux sends its first record immediately on attach, so a handler
// attached after `exec()` returns is attached after that record could
// already have been forwarded. Nothing about the parsing below changes —
// the stream is still read and classified exactly as before.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// the stream is still read and classified exactly as before.

@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

PR #771: ♻️ Extract terminal providers into workspace packages (#717)

94 files, +4326 / -2155

Scope

🔴 PR has 6481 lines changed. Split into focused PRs.

🟡 6481 lines changed. PRs under 400 receive more thorough review.

🟡 94 files changed. Are all changes related?

🟡 Changes span 15 directories.

🟡 PR mixes config and source changes.

Structural

Oxlint structural signals:

  • no-unused-vars ×28: packages/core/src/agent/function-components.ts, packages/core/src/agent/launch-owner.ts, packages/test-agent/src/components.ts (+4)
  • no-unnecessary-type-assertion ×9: packages/grid/src/grid.ts, packages/core/src/grid/journal.ts, packages/core/src/expand.ts
  • no-empty-function ×8: packages/grid-tmux/src/pane-worker.ts, packages/test-agent/src/components.ts, packages/grid-tmux/src/tmux.ts (+3)
  • no-redundant-type-constituents ×5: packages/grid-tmux/src/pane-channel.ts, packages/grid-tmux/src/pane-child.ts, packages/grid-tmux/src/attach-client.ts (+1)
  • no-unnecessary-type-arguments ×2: packages/grid-tmux/src/pane-channel.ts, packages/grid-tmux/src/tmux-grid.ts

Slop

  • packages/cli/src/cli.ts:2844// What presents a grid on this host. Deno and the compiled binary

Oxlint slop signals:

  • no-console ×3: packages/cli/src/cli.ts
  • no-inferrable-types ×2: packages/core/src/expand.ts

Static Analysis

Oxlint: 127 diagnostics across 24 files (22 rules)
Density: 0.029 violations/added-line

no-unused-vars (28): packages/core/src/agent/function-components.ts, packages/core/src/agent/launch-owner.ts, packages/test-agent/src/components.ts (+4)
consistent-function-scoping (12): packages/grid-tmux/tests/fixtures/fake-tmux.ts, packages/grid-tmux/src/pane-worker.ts, packages/acp/src/provider.ts
no-floating-promises (11): packages/grid-tmux/src/pane-channel.ts, packages/acp/src/provider.ts, packages/grid/src/grid.ts (+4)
no-unnecessary-type-assertion (9): packages/grid/src/grid.ts, packages/core/src/grid/journal.ts, packages/core/src/expand.ts
no-shadow (8): packages/grid/src/controlled-composite.ts, packages/test-agent/src/controller.ts, packages/grid/src/posix-launcher.ts (+3)
no-empty-function (8): packages/grid-tmux/src/pane-worker.ts, packages/test-agent/src/components.ts, packages/grid-tmux/src/tmux.ts (+3)
unbound-method (6): packages/test-agent/src/controller.ts, packages/grid-tmux/src/provider.ts, packages/cli/src/cli.ts (+1)
no-unsafe-type-assertion (6): packages/acp/src/provider.ts, packages/cli/src/deno.ts, packages/cli/src/cli.ts (+1)
no-redundant-type-constituents (5): packages/grid-tmux/src/pane-channel.ts, packages/grid-tmux/src/pane-child.ts, packages/grid-tmux/src/attach-client.ts (+1)
consistent-return (5): packages/grid-tmux/src/pane-channel.ts, packages/core/src/document-validation.ts, packages/grid-tmux/src/provider.ts (+2)
no-base-to-string (5): packages/core/src/agent/function-components.ts, packages/core/src/expand.ts
no-underscore-dangle (4): packages/grid-tmux/src/pane-protocol.ts
no-useless-spread (4): packages/grid-tmux/src/pane-channel.ts, packages/acp/src/provider.ts
no-array-sort (4): packages/core/src/document-validation.ts, packages/grid-tmux/src/layout.ts, packages/grid-tmux/src/tmux-grid.ts
no-console (3): packages/cli/src/cli.ts
no-inferrable-types (2): packages/core/src/expand.ts
no-unnecessary-type-arguments (2): packages/grid-tmux/src/pane-channel.ts, packages/grid-tmux/src/tmux-grid.ts
no-useless-fallback-in-spread (1): packages/core/src/expand.ts
no-unmodified-loop-condition (1): packages/cli/src/cli.ts
no-unnecessary-type-conversion (1): packages/core/src/agent/function-components.ts
restrict-template-expressions (1): packages/core/src/expand.ts
no-implied-eval (1): packages/core/src/expand.ts

Correctness

No extraneous code patterns detected.

@taras
taras force-pushed the agent/issue-717-terminal-packages branch from 4f0e333 to 3d5a555 Compare September 11, 2026 01:08

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 4 redundant comments. Inline suggestions to remove them below.

// child inherits this process's, so a pane collapsing it to `{}` started
// the program with no environment whatsoever — no `TERM`, so no colour, and
// no `PATH` or `HOME` either. An environment that *is* supplied crosses
// exactly, gaining nothing ambient.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// exactly, gaining nothing ambient.

// tmux sends its first record immediately on attach, so a handler
// attached after `exec()` returns is attached after that record could
// already have been forwarded. Nothing about the parsing below changes —
// the stream is still read and classified exactly as before.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// the stream is still read and classified exactly as before.

if (segment.name === "Terminal.Grid") {
// No raise() here, like the branches above: expandTerminalGrid
if (segment.name === "Grid") {
// No raise() here, like the branches above: expandGrid

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// No raise() here, like the branches above: expandGrid

// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// and nothing else, which is exactly the difference this closes.

taras and others added 13 commits September 10, 2026 21:29
`<Terminal.Grid>` and `<Terminal>` become reserved core structural syntax.
This Story owns the authored structure alone: the grammar, the placement
rules, and the row-major layout a grid derives. No terminal authority,
provider, pane execution, shell, durability or replay is built here.

The grid's closed props hold one required positive-integer `columns`; a
pane's hold one required non-empty `title`. Titles are display labels and
identify nothing — a pane's structural identity is its ordinal among the
grid's direct children, and rows are derived in authored row-major order
with the last row left short.

`structural-rules.ts` decides what the source says, so expansion and
document validation cannot disagree about it: only whitespace and direct
`<Terminal>` panes may appear in a grid, and an empty grid, direct text, a
non-pane element, a control structure that would produce panes, a nested
grid, and a pane written anywhere else are refused. `terminal-grid.ts`
places the panes once `columns` and each `title` are known.

A grid the grammar accepts runs until a terminal provider would be asked
for one. This build installs none, so it refuses there, before any pane
body expands or a default shell starts, and carries the layout it derived
beside the refusal.

Evidence rows TG1-TG4: the new structural suite owns TG1, TG2 and TG4; the
catalog, validation and `xmd syntax` suites own TG3.
Both descriptions lead with what the author can do and show the invocation
to copy. The placement rule, the row-major detail and the title's display
role are the catalog's structured fields and §6.21's to state, not a second
reference entry beside the forms.

The frozen catalog entries in `syntax-catalog.test.ts` mirror the two
declarations, so they move with them.
The replaceable seam a terminal grid executes through, before any of the
execution that uses it.

`packages/runtime/terminal.ts` is the contextual provider: `prepare()`
builds the whole composite while it stays hidden, `attach()` shows it once
every pane is ready, and `destroy()` gives the root terminal back. The
request is provider-neutral — columns, rows, and the authored panes with
their derived positions — and names no terminal, socket, process or window.
Middleware may observe, narrow, refuse, wrap or delegate; presentation
never decides an outcome, so `update()` receives states core has already
settled on.

`packages/core/src/terminal/authority.ts` mints one-use pane claims for one
request's ordinals. A claim admits one interactive operation at a time on
its pane and holds that pane's readiness latch. Two claims do not contend,
which is what lets panes stay interactive together.

`packages/core/src/terminal/pane.ts` is the seam interactive work inside a
pane reaches for, so it runs as that pane's owner instead of competing for
the root foreground lease. Absence means "not in a pane".

Evidence: `packages/runtime/tests/terminal-provider.test.ts`, 11 rows.
`runTerminalGrid()` owns the lifecycle the reader sees: it takes the run's
one foreground-terminal lease, flushes root output, prepares the composite
while it stays hidden, starts every pane concurrently, and attaches only
once every pane has reported a spawn through its claim.

Ordering is the contract. The lease and the composite are both scope-owned,
so success, failure and cancellation all release the terminal and destroy
exactly the composite that was prepared — there is no path that skips
teardown. A pane that settles without ever reporting a spawn fails startup
rather than being presented as a running pane. Before the barrier a pane
failure fails the whole grid closed; after it, the failure is that pane's
status and its siblings keep running. Close cancels a live pane as `closed`,
which is not a failed pane, and the grid fails with the first failed pane in
authored order.

`display()` and an `onUpdate` hook complete the provider surface: a pane's
rendered text goes to that pane, and a suite reacts to a state the grid
decided rather than waiting and hoping.

Evidence: `packages/core/tests/terminal-grid.test.ts` (15 rows) and
`packages/runtime/tests/terminal-provider.test.ts` (11 rows). The readiness
barrier row was verified by removing the barrier: it fails without it.
`<Terminal.Grid>` now executes. Each authored pane becomes a concurrent
child of the grid: a self-closing pane runs the host's default shell
through its claim, and a paired pane expands its own content in a scope of
its own.

A pane inherits the bindings, providers, configuration and working
directory visible where the grid was written, and keeps everything it
creates afterwards. Its `<Break>` has no loop to exit, its `<Return>` has
no enclosing value body to claim, and its checked failures settle the pane
rather than reaching the root or a sibling. A pane's rendered text is
displayed in that pane; the grid itself renders `""`, so the root output
holds what surrounds the grid and no pane display at all.

#729's five execution-dependent rows move here, where they assert the
layout against the request the provider actually receives rather than
reading it off a refusal's cause — the structural suite keeps the grammar,
placement and pure-layout rows it owns. Moving them was authorized rather
than assumed.

Evidence: 22 rows in `packages/core/tests/terminal-grid.test.ts` and 11 in
`packages/runtime/tests/terminal-provider.test.ts`; the whole Deno core
(350) and runtime (14) suites pass.
`paneWork()` never read the grid element it was handed. Its caller has it,
and a pane's own diagnostics are positioned at the pane.
…#730)

Restores the authority boundary on the `AgentProviders` handshake, and puts
each pane on its own durable child coroutine.

**The boundary.** `TerminalGrids` is routing and only routing: `open()`
answers `unknown` and core throws the answer away, so middleware may
observe, narrow, refuse, wrap or delegate but can never authorize. The
capability that takes the leases, mints pane claims and settles a grid is a
non-contextual authority delivered straight to the registered provider
through a one-use install handshake. Core mints one identity-bearing request
per expansion; presenting a copy, a rebuilt lookalike, a changed request, an
already-presented one, or one from a superseded installation generation
authorizes nothing, and a handler that answers without presenting settles
nothing.

**Durable children.** Each pane is a durable child of the grid, allocated in
authored order, so a pane's identity follows its ordinal rather than the
order the runtime scheduled it in. The layout is recorded in the parent
coroutine before the lease and before any provider is contacted.

**Ordering.** A pane that settles before attach keeps the status it settled
to instead of being overwritten with `running`, and simultaneous startup
failures are selected by authored ordinal rather than by whichever rejected
first. Each pane also expands under a counter of its own, so two concurrent
panes cannot take block identities that depend on which ran first.

`durableSpawn` could not be used: the task it returns is spawned inside the
ephemeral effect's own scope, which closes as the effect resolves, so
awaiting it throws `halted`. It has no call sites or tests upstream.
`durableAll` is the exercised primitive and is what the panes and the grid
child use.

Evidence: 30 rows in `packages/core/tests/terminal-grid.test.ts` and 10 in
`packages/runtime/tests/terminal-provider.test.ts`; core 349, runtime 15.
`durableSpawn` returned a task spawned inside the `ephemeral` effect's own
scope, and that scope closed as the effect resolved — so every
`yield* task` threw `halted`. It had no call sites and no tests. It now
starts the child in the routine's own scope, so the task outlives the call
and can be awaited or halted by whoever asked for it.

A retained `Close(cancelled)` meant one thing to the code and two things in
practice. Under `durableRace` and `durableAll` it is a race loser or a
fail-fast sibling, and the same combinator cancels it again — those keep
DEC-024 exactly. Under `durableSpawn` nobody cancels it a second time, so
suspending hung the resumed run forever. `runDurableChild` now takes an
explicit `CancelledChildPolicy`, fixed at each combinator's call site and
never chosen by a caller. Resuming uses a new internal
`ReplayIndex.reopen()`, which forgets one coroutine's retained Close while
keeping its yields — so the child continues its own history rather than
restarting, and the divergence guard stops reading the remaining effects as
a coroutine continuing past its own close. Neither it nor `disableReplay` is
exported.

DEC-039 records the policy and marks DEC-024's invariant as superseded in
part: it assumed every cancelled child belongs to race or all.

The grid uses the repaired primitive: the whole grid is one durable child,
each pane is its own durable child allocated in authored ordinal order, and
each pane task is observed outside its child — so a replayed pane's retained
outcome publishes its status and satisfies the readiness barrier without
entering a body, a shell, or a launcher.

Evidence: 9 rows in `packages/durable-streams/tests/durable-spawn.test.ts`
(lifetime, completed replay, interrupted resume, retained-history
continuation, and both combinators keeping their own policy); 30 rows in
`packages/core/tests/terminal-grid.test.ts`. durable-streams 32, core 349,
runtime 15.
Two decisions exposed while implementing #730, and no implementation.

**DEC-040 — a cancelled child records why.** DEC-039's `"resume"` fired on
every retained `Close(cancelled)` under an incomplete parent, which revives
work a caller deliberately halted: the record of a deliberate `task.halt()`
and the record of an interrupted run are the same event. The cancelled close
now carries `cancellation: "caller"` or `"unwound"`, written by whichever
path cancelled the child, and `"resume"` continues only `"unwound"`. A
deliberate stop suspends, which is DEC-024's reproduction argument applied
to a caller instead of a combinator; a legacy record with no reason reads as
`"caller"`, because refusing to revive is the safe direction. The reason is
retained evidence, not authority: nothing outside `runDurableChild` reads
it, and no caller chooses a policy.

Terminal grids need nothing wider. A grid halts its pane tasks at close, so
those retain `"caller"` — and the grid child completes, so a resumed run
short-circuits the region and never reaches them. The case that must resume,
an interrupted run, unwinds and retains `"unwound"`.

**TG17 narrows to the resolved layout.** A continuation executes the root the
journal retained; the supplied source is not read, compared or refused
(proved in #722). A grid's authored structure — pane count, order, form — is
therefore fixed for the life of a journal and cannot differ between runs, so
comparing it compares a value with itself, which is why the refusal never
fired. What a fixed retained document still resolves differently is
`columns` and each `title`, through prop-borne values, since props are not
restored. Those refuse before the lease and before provider contact.

Authored-structure change is a root-definition compatibility question, not a
grid one. Root-definition authority is preserved rather than overridden by a
pre-replay comparison against the current file, and the versioned root
boundary that would refuse a changed source stays open work.
**DEC-040.** A cancelled Close now records why: `cancellation: "caller"`
when the owner halts the task `durableSpawn` returned, `"unwound"` for
anything involuntary. `durableSpawn` resumes only `"unwound"`; a deliberate
stop suspends until the caller's deterministic control flow halts it again,
and a record with no reason reads as `"caller"` so nothing legacy is
revived. `durableAll` and `durableRace` keep DEC-024 whatever the reason
says.

The halt is intercepted without changing the public `Task` surface: the
returned task carries every member the real one defines, copied with its
prototype, and only `halt` is replaced. A proxy cannot do this — a task's
members are read-only and non-configurable, so a `get` trap is required to
hand back exactly what the target holds.

The reason had to survive three boundaries that were dropping it: the
protocol parser, the observable copy, and — the one that actually mattered —
`detachResult`, which froze every cancellation down to `{ status }`.

**TG15.** The harness's `attached` and `pastGrid` signals are now separate,
and a run that expects its grid to complete waits for the sibling *after*
the grid before halting the root at `<Hold />`. That is what leaves a
completed grid child under an incomplete root, which is the only state in
which a completed region can be observed replaying at all. Both a successful
grid and a contained failed one replay their exact retained result with no
provider, pane content, shell or launcher work, and each row asserts the
grid child genuinely recorded a terminal close. No timeouts.

**TG17.** Prop-borne `columns` and `title` change independently against one
fixed retained document — the only things a fixed retained root can still
resolve differently — and each refuses with zero provider observation. For
supplied-file changes to pane count, order and form, the continuation opens
the retained structure rather than the file's, asserted request-for-request.
The retained record carries every authored pane's ordinal, title, form and
derived position.

`readLayout()` parses totally: the layout object and every pane field, with
missing, extra, mistyped, out-of-position and self-inconsistent records all
refused rather than half-read.

Evidence: durable-spawn 14 rows, terminal-grid 36 rows, structural 13,
provider 10. Packages: durable-streams 33, core 349, runtime 15,
workflow 172.
#730)

**The harness cannot pass a hung replay any more.** `runInterrupted()` had a
1500ms timer racing its signals, so a replay that hung returned a
DocumentRun that looked finished; it also slept a fixed 5ms to let records
land. Both are gone. It now waits only on events the run produced:
`attached`, `pastGrid`, and a new `panesSettled` for the rows that read pane
records — a pane's status is published only after its durable child returned,
so counting settled panes is also counting durable pane closes. A replay
that hangs now reaches none of them and hangs the row.

**TG15's failed case is a real contained failure.** A pane that fails before
attachment fails the whole region, so the old document could not both fail
and continue. The failing pane is now a shell that starts, waits for
attachment, and only then exits badly — contained as that pane's status,
with the grid settling as failed and the document carrying on. Both runs
capture the printed errors, and the row asserts the replayed run produced
the same ones, reached `PAST_THE_GRID`, and did no provider, pane, shell or
launcher work.

**DEC-040 gets boundary tests where the evidence actually travels.**
`parse.test.ts` round-trips both reasons to the same bytes, keeps a legacy
absence absent, and refuses an unrecognised reason at `$.result.cancellation`.
`retained.test.ts` proves retention and `consumable()` carry both reasons,
leave a legacy absence absent, drop an unrecognised one to the safe default,
and that the reason reaches the replay index. The DEC-040 rows in
`durable-spawn.test.ts` no longer coordinate by delay: a child says when it
is running, and the caller says when it has halted.

**Malformed retained layouts** are covered by replaying a real journal with
only its layout entry replaced — a missing member, an extra one, a mistyped
one, a pane out of position, and a record that disagrees with itself. Each
refuses with zero provider observation.

The `durableSpawn` doc comment no longer says every retained cancellation is
an interrupted run.
The DEC-040 block still slept where it meant to synchronise — my previous
replacements silently failed to match after the file was reformatted, so
none of them landed. The block is rewritten rather than patched.

Every row now waits on something the run reported. A shared `living()` child
resolves a `started` signal and then suspends, so each row halts or unwinds a
child that is provably live rather than one a delay happened to reach. The
caller resolves `halted` after performing its deliberate halt, so a run is
interrupted only once both facts — the deliberate stop and the interruption —
are in the journal.

Non-revival is established by control flow rather than by waiting: the
resumed run reaches its own `task.halt()` and says so, and a revived child
would have recorded its mark before the caller could get there. The
legacy-absence row signals once the child has been asked for and the request
returned. No new timeout, and `sleep` stays imported because the lifetime
rows above still use it deliberately.

`retained.test.ts` drops the cast and the row it supported: rejecting an
unrecognised reason is the parser's, proved there, and retention proves only
that `"caller"`, `"unwound"` and a legacy absence survive.
…730)

**`Symbol.asyncDispose` bypassed the deliberate-stop evidence.** The task
`durableSpawn` returns copied it from the original unchanged, so
`await using` — or an explicit `task[Symbol.asyncDispose]()` — recorded
`cancellation: "unwound"` and the next run revived the child. `halt()` and
the async dispose are the same decision spelled two ways, and both are now
observed. Awaiting a task is not a stop and is left exactly as it was. A
regression disposes a live task, asserts the retained reason is `"caller"`,
resumes the journal, and proves the body is not entered again.

**Reader close no longer halts panes.** It asks them to stop: a pane races
its work against a close signal, settles as `closed`, and records that
outcome as its own. Nothing on the ordinary close path is a caller-cancelled
child any more, so a resumed run restores a pane the reader closed rather
than finding a cancelled child it must either re-enter or wait on forever.
Statuses are published before anything is awaited, so a pane with slow
finalizers cannot delay the outcome the grid already knows.

**§6.21 now agrees with architecture.md and TG17.** Partial replay compares
the resolved layout — columns and titles. Pane count, order and form come
from the retained root and cannot diverge within a continuation, so a
changed supplied file is ignored in favour of the retained structure;
refusing a changed authored structure is a root-definition boundary this
specification does not yet define.

DEC-040 is unchanged and nothing deliberately stopped is revived.
A terminal grid may not report a pane settled, admit the next launch into it, or
let the document continue while something a launch started can still act. A PID,
a delivered signal, an attach client going away and an elapsed timeout each
establish none of that.

`packages/runtime/terminal-processes.ts` is what does: the process table,
terminal holders, signal delivery and reachability, behind one host seam whose
own default refuses every question. Refusing is the point — "nobody is there"
and "I cannot see" are the two answers a quiescence proof must never confuse, so
a host that installs no observer stops the document rather than reporting a pane
quiet it never looked at. The POSIX handler answers with `ps` and `lsof`; the
`lsof` sweep is the expensive half and grows with the process count, which is
why it is behind the seam rather than inlined.

Two shapes carry the rule. `paneOccupants()` takes the snapshot — the child, its
descendants, its process group — and must be taken *before* the first signal,
because a killed child's children reparent to init and a later reading names
fewer processes than the launch actually started. `establishQuiescence()` asks
about every one of them and about the terminal, and reports everything still
true rather than the first thing it found.

Nothing here decides policy. It reports; the pane worker finishing a launch and
the provider tearing a grid down decide what the report means.

Tier TP proves the difference between establishing and assuming: a host with no
observer refuses, the POSIX reader finds this process in the real table, a
snapshot read after a kill names nobody, and a pane whose child is gone is still
not free while a descendant runs or anything else holds the terminal.
`select-layout tiled` picks its own column count from the window's dimensions,
so the same four panes are 2×2 in one terminal and 4×1 in another. An authored
`columns` has to be told to tmux rather than asked of it.

`packages/cli/src/terminal/layout.ts` writes the description tmux prints in
`#{window_layout}` and accepts back: a checksum, then a tree of cells sized
row-major from the pane count and the authored column count. A final row with
fewer panes than columns spans the row, because tmux has no empty cells and the
author wrote panes rather than a rectangle.

One thing the string cannot do is place a particular pane — tmux fills the
leaves in window-list order and ignores the pane ids they name — so authored
order is imposed afterwards by swaps. `swapsInto()` says which, produces none
for an order that is already right, and refuses a window that does not hold a
pane the author wrote instead of putting some other pane there.

Tier TX checks the geometry at four terminal sizes, that the cells tile exactly
with one separator between them, that the checksum tracks the tree, and all
three swap cases.
A pane's initial process is a worker that owns the pane's terminal for the
pane's whole life, and everything it does is asked of it over a socket only this
invocation can reach.

**The channel.** One directory per grid, mode 0700, directly under `$TMPDIR`
because a Unix socket path is capped at 104 bytes and a directory named after a
repository path spends most of that first. Inside it, one socket and one
mode-0600 token per pane, both written before any pane exists, so a worker that
starts finds its socket listening rather than racing it. Admission is the whole
boundary: a connection is admitted when its first frame is a `hello` naming this
pane's ordinal and carrying this pane's token, and a connection that says
anything else, says it late, names another ordinal, or arrives after that pane is
admitted is closed without being answered. The token is single-use because the
worker removes the file as it reads it.

**What crosses it.** The exact argv vector, working directory and environment.
tmux has a command parser, and a command parser is a place where an argument can
become two arguments, or a quote, or a `;`. tmux is told a directory and an
ordinal, and that is all its parser ever sees.

**The worker.** `xmd terminal-worker <ordinal> <dir>` — reusing this executable
rather than shipping a second script, which is what makes it work in the
compiled distribution. It is in no command table, so it is in no help output and
no catalog, and naming it grants nothing: without a pane's single-use token
nobody answers. It is dispatched at the entrypoint, before `main()`, and runs
under `run()`, because `main()` binds SIGINT to its own shutdown and would exit
130 on the first `^C` typed into the pane — the keystroke the foreground child is
supposed to receive. It ignores SIGINT, SIGQUIT and SIGTSTP itself so the child,
which gets default dispositions across `exec`, is the one interrupted.

**Readiness and settlement, kept apart.** Readiness is the runtime's `spawn`
event and nothing earlier; a missing executable delivers `error` instead of it,
never after it. Settlement is the escalation and sweep that follow — a child that
exited on its own may have left descendants in its group or an orphan holding the
terminal, and the pane is not free until neither is true. `exited` is reported
only after that, so the next launch is refused while a sweep that would reach it
is still running.

One hazard the evidence found: the settlement sweeps the process group it is in,
and a worker that was not a session leader would be sweeping whatever started it.
In a pane tmux makes it one — but a settlement one signal away from killing the
run that started the grid is not something to leave to the topology being what it
should be, so the sweep now never reaches an ancestor of the worker.

Tier TW proves it with a real worker process over a real socket and no tmux at
all: the modes, the removal, the handshake, three ways of failing it, awkward
argv crossing intact, a child that never starts, one-live-child exclusivity,
display written and never read, and shutdown's final sweep.
One invocation-private server per grid, on its own socket, started with
`-f /dev/null` so a reader's `.tmux.conf` cannot redecide an authored layout. A
pane per authored ordinal, each running that pane's worker — tmux's parser sees
an ordinal and a directory and never a launch's argv. Nothing is visible until
`attach()`, which core calls only after every pane has reported a start.

Three clients, kept apart because they answer different questions. The visible
one is the reader's. The control one attaches `-f no-output`, so pane bytes
never travel through this process, and what it reports is how reader detach,
server stop and control loss are told apart — an attach client's exit code
cannot tell them apart, being 0 after `detach-client`, 0 after `kill-session`
and 1 after `kill-server`. The workers are not clients at all; they are the
panes.

Teardown is registered before the first command, so a composite that fails
half-built still takes its server down. A detach is *asked for* before anything
is signalled, because a client that leaves restores the terminal and one that is
killed cannot. `stop()` establishes the server pid is unreachable and the server
refuses its session — never the socket file's absence, which outlives it.

`probeTmux()` answers the prerequisites before a server exists: a terminal to
divide, and a tmux new enough to divide it as an authored layout needs.

Tier TG runs against a fake server that reproduces the behaviours this code
exists to work around — a split inserts its pane into the window list after the
one it split, and a layout string's leaves are filled in window-list order with
the ids in them ignored. What is not faked is the composite: the same layout
string, the same swap decisions, and real control-mode lines from a fixture
process through the same splitter and classifier.

Both halves of the ordering claim were broken on purpose: removing the swaps
fails TG2, and a fake that honours the leaf ids fails TG2 as well — so the row
is passing because the composite imposes the order, not because the two happened
to coincide.

Stated plainly, and not claimed here: a fixture client inherits a pipe, so it
cannot restore a terminal it never had. That a real `tmux attach` gives the
reader's terminal back when asked to detach is #726's evidence on real tmux.
**The visible client is not a pane child.** A pane child is settled by sweeping
its process group and its terminal, because a pane's terminal belongs to the
grid. The reader's terminal belongs to the run: everything holding it is XMD,
whatever started XMD, and the rest of XMD's foreground group. A settlement of
that shape aimed at the attach client is a settlement aimed at the document.
`attach-client.ts` owns exactly one process instead — asked to detach first,
through tmux, and only then insisted on by pid, with no group, no descendants
and no terminal sweep anywhere in it.

**A successful `kill-server` is not proof.** Teardown now succeeds only once the
recorded server pid is unreachable and the server refuses its own session, and
throws a provider-neutral `TerminalTeardownFailed` when either is still unproved
at the bound. The rule is in the resource finalizer too, so a preparation that
failed halfway is held to it as well.

**Nothing private in a diagnostic.** `TmuxCommandFailed` carries the step's name
and nothing else — not the arguments, which hold the socket path, session name,
pane and client identifiers and the worker's private directory, and not stderr,
which tmux writes paths into. A provider's topology stays private on the paths
taken when something goes wrong, which are the paths a diagnostic is read on.

**Closures before removal.** The private directory is removed only after every
accepted socket and every listening server has actually closed — counted from
their own `close` events rather than from having been asked.

Three regressions, each broken on purpose and re-run:

- TG11 gives the process table company — XMD, its parent, two more in the same
  group, and four holders of the reader's terminal — and proves the escalation
  reaches the client's pid alone. Settling it like a pane child fails it.
- TG10 plants markers in the socket, session, pane and client identifiers, the
  worker directory, the arguments and stderr, and proves none reaches the
  surfaced error. Restoring raw arguments fails it.
- TG12 counts real closures at the moment of removal. Not awaiting them fails it.

Also conformed to the repository's rules: `@effectionx/fs` for stat, rm,
readTextFile and writeTextFile, with `node:fs/promises` kept only for `chmod`
and `appendFile`, both adapted through `until`; the client fixture is an
Effection operation; and the newly introduced `as const` assertions are gone in
favour of typed values.
`end()` sent SIGKILL and then discarded what the wait after it established, so
a client still holding the reader's terminal was reported as torn down. The
shared `stop()` resolved successfully on top of that, and the document carried
on.

It now establishes the client is gone, and raises a provider-neutral teardown
failure when it is not — so `stop()` rejects and the document stops instead.
`leftWithin()` also looks once more at the boundary itself rather than falling
back on the cached exit event: a client that left during the final interval is
gone, and reporting it as still there would be reporting a stale reading.

The boundary is unchanged and still narrow: detach is asked for through tmux
first, and every signal after that names the exact client pid. Nothing inspects
or signals its process group, its descendants, or the holders of the reader's
terminal — on this terminal, each of those is the run itself. The refusal
carries none of the socket, session, client name, argv, environment, terminal
or host message.

TG13 models a client that survives the ask, SIGTERM and SIGKILL: teardown
refuses, the signals delivered are exactly SIGTERM and SIGKILL to the client's
pid, three same-group bystanders and three holders of the reader's terminal are
untouched, and no planted marker reaches the refusal. TG11's successful
escalation is unchanged.

Reinstating the discarded result fails TG13 and leaves TG11 green, which is the
discrimination the two rows are for.
`provider.ts` is where #730's provider-neutral request meets tmux: it prepares
the private channels, the hidden server and the panes, resolves each pane's
worker command before a server exists, and hands core a composite it drives
through its own lifecycle. Nothing tmux-shaped crosses in either direction.

The reader leaving and the host's terminal going away settle the same
`closed()`. That is deliberate: a hangup is not a second teardown path to keep
honest separately, it is the ordinary structured close every other stop uses.
The SIGHUP listener is a resource, so it is removed with the run rather than
answering for a terminal the next one is using.

`host.ts` states which hosts present grids. The Deno entrypoint and the compiled
binary supply `foregroundTerminalGrid()`; every other caller gets
`unsupportedTerminalGrid`, which still opens the installation so a grid is
validated and refused by core rather than being silently absent. Node and Bun
therefore catalog and validate the same grids and open none — threaded through
`AgentStack` beside the machine-session assembly, which is the same shape this
repository already uses for "Deno supplies the live one, Node and Bun supply the
one that installs nothing".

architecture.md's terminal-grid inventory row said "implementation unbuilt",
which four layers had made untrue. It now says what each Story built, that the
controlled provider remains the authority for core lifecycle semantics, that
this Story's evidence uses a fake tmux with real tmux behaviour remaining

Checkpoint 3's evidence is not in this commit: the Node/Bun refusal row, the
SIGHUP-through-host-installation row, and the CLI regressions are still to come.
Implements architecture commit 802b07d.

`TerminalComposite.launch(ordinal, request, spawned)` is required of every
composite. Core closes the pane-scoped launcher over it and the pane's authored
ordinal, so after claim admission and the pane flush a `<Session.Launch>`
written in a paired pane reaches *that pane's* terminal. The ordinal lives in
core's closure and enters no native request, Agent request, session key,
construction route, durable phase, result or diagnostic.

The pane launcher is now the end of the chain. Middleware written nearer the
authored launch still composes in front and may observe, wrap, refuse or
short-circuit; what it can no longer do is reach past, because past it is the
root foreground launcher and the root terminal is the one thing a pane exists
to avoid. A composite that cannot run a pane's launch refuses — there is no
fallback, because the only thing to fall back to is the wrong terminal. Root
`<Session.Launch>` is untouched.

The tmux composite sends the exact command vector, working directory and
environment over the pane's authenticated channel; tmux's parser sees a
directory and an ordinal. `shell()` stays separate and keeps deriving the
executable from live host policy. `spawned` is invoked only for the
worker-observed runtime spawn event.

Also fails closed on settlement: `requireQuiescent()` is the rule everything
downstream is conditional on, and a settlement that could not prove the pane
free no longer clears the pane, reports success, or admits another launch.

TG20 proves the endpoint against real workers on real sockets with a fake tmux
that now starts the pane commands it is given, and with no `<TestAgent>` or
nearer launcher in front: exact argv, cwd and environment arrive at the pane's
authenticated worker; a root-foreground-launcher sentinel is never entered while
a root launch still reaches it; distinct panes launch concurrently; and a pane
the composite cannot serve refuses.

Tier GN is rewired through the endpoint, which is what exposed the gap: before
this, its pane launches reached `<TestAgent>`'s launcher and nothing could tell
that from reaching the pane. GN now separates the two — `launches` at the pane
endpoint, `agentLaunches` at the root route — and GN3 and GN8 assert both.
Carries the three evidence gaps from 7511e77 and the six repairs.

**TW13 now proves the worker.** The rejected environment switch is replaced by an
injected child seam: `runPaneWorker` takes what it starts, so a suite can run the
real worker in-process against a real channel with the one thing it cannot
arrange in another process — a child whose settlement cannot say the pane is
free. After `quiet:false` the worker clears no live entry, reports no
settlement, starts no second child, and refuses.

**TG20c is a discriminator.** Each child announces itself and blocks until both
have; a serial pair would wait for a start that had not happened.

**TG20e proves cancellation.** `runInPane()` owns it: registered before the
launch is asked for, a cancellation sends the worker's cancel and waits for a
settlement that proves the pane free. A cancelled launch does not return while
its child is live — TG20e reads the child's own pid and finds it gone.

**Process observation fails closed**, in `deno-terminal-processes.ts` behind the
runtime-named boundary Deno, the compiled binary and their pane workers install.
`kill(pid, 0)` establishes absence only for ESRCH; EPERM is a process that
exists and this user may not signal, so it raises rather than reading "I may not
ask" as "nothing is there". A `ps` that would not run is not an empty table. Only
`lsof -t`'s documented exit-1-with-no-output is read as "nobody".

**Every listener is scope-owned.** No `.once()` and no `{ once: true }` in the
touched production code: named handlers, removed by the scope that installed
them, and kept installed through any wait they resolve. The worker's SIGINT,
SIGQUIT and SIGTSTP handlers are its run scope's. TW14 counts them across event
delivery, no delivery, startup failure and cancellation.

**One ordered teardown.** `tearDown()` is idempotent and covers both core's
`destroy()` and a preparation that failed halfway: detach and prove the visible
client stopped, ask every worker to shut down, await each settlement, terminal
sweep and goodbye, refuse on anything unproved, and only then stop the server and
prove it gone. Sockets, their servers and the private directory come down after
it, in the scopes that own them.

**SIGHUP is cancellation, not a reader close.** A reader who detaches selects a
close outcome; a terminal that is gone cancels the document through the ordinary
structured path, runs the whole teardown, and lets no following sibling run.

**Hosts state what they are.** Deno and compiled install the provider and the
observer together; everyone else installs neither and still validates. TH1–TH3
cover a missing terminal, an unusable tmux, and a host with no provider.

The inventory now says what was built and what the evidence is: fake tmux with
real workers and real sockets, with real tmux behaviour on macOS remaining #726's.
…ce (#732)

**Observation carries stderr, and reads only what it understands.** `lsof -t`
exits 1 saying nothing when a file has no holders, and exits 1 *with a
diagnostic* when it could not look; without stderr those are the same status,
and one means "nobody" while the other means "I do not know". Only the exact
empty shape is accepted. A successful run whose lines are not all readable, and
a `ps` reading with lines it cannot parse, now refuse rather than answering with
the subset they happened to recognise — a sweep satisfied by that is a sweep
that never saw what was there. TP2f and TP2g cover both.

**Teardown is one retry-safe lifecycle.** It is marked complete only after it
succeeds, so a repeat caller observes the same teardown rather than skipping
unfinished work, and a teardown that failed is retried rather than remembered as
done. Per worker, in order: shutdown asked, settlement required, a goodbye that
names no surviving holder, then the channel closing — a worker that was gone,
disconnected, or stopped part-way is a failure, not a success. Channels close
before the server is stopped, and the server's absence is proved before the
private paths go. Every acquired resource is still attempted after an earlier
failure, and the first failure is what surfaces.

**Every listener is scope-owned, including the frame reader.** `readFrames()` is
a resource whose named data, close and error handlers come off on delivery, on a
frame that does not parse, on cancellation and on ordinary exit. Startup
listeners are removed once startup resolves; the ones a settlement still needs
stay until the scope ends. TW14 now counts on the emitters themselves — the
child process, and the channel's sockets and servers — across delivery, no
delivery, startup failure and cancellation, with the cancellation coordinated by
the child's own start rather than a sleep.

**Host evidence.** TH4 drives the hangup through the operation the foreground
installer wraps `Execution.document` with: it stays structured cancellation,
runs the complete teardown, and lets no following sibling run. TH5 exercises the
assembly the runtime-named entrypoints call — provider and observer together, or
neither. CL6 and CL7 add the CLI grid regressions.

One thing CL6 found and records rather than hides: a grid under a pipe is
refused at the run's foreground lease, before any provider is contacted — and
the wording it gets is the foreground launcher's, which names `<Session.Launch>`
though the document writes none. The refusal is correct and early; the sentence
is aimed at the wrong feature.

The inventory no longer says the required pane endpoint remains to be
implemented, and claims the completed teardown now that it is there.
**Every registration is named and owned.** `net.createServer(cb)` and
`server.listen(cb)` both register anonymous listeners nothing can take off
again; both are now named handlers, with `connection` removed by the channel's
scope and `listening`/`error` removed synchronously once the listen resolves,
however it resolved. `readFrames()` takes all three protocol handlers off the
moment that reader terminates — a close, an error, or a frame that is not the
protocol — and tells its consumers, because a reader that detached silently
would leave them waiting on a conversation that ended. The resource cleanup
stays for the paths that terminate nothing: a cancelled scope, and a socket that
never says anything.

`spawn` and `error` are the two answers to one question, so whichever arrives
takes both off; `exit` stays, because the settlement is still waiting on it. The
same rule for the visible attach client.

**TW14 is discriminating.** It holds references to the child processes, the
accepted socket and the servers, and asserts their listener counts after each
scope ends — delivery, no delivery, startup failure and cancellation, with the
cancellation coordinated by the child's own start signal. It caught two real
misses while being written: the server's `connection` handler was still
anonymous, and the frame reader's early detach had stopped closing its queue.

**`PaneChannels.close()` publishes before it closes.** The in-flight settlement
is created and stored first, so a concurrent caller shares this close rather
than starting a second one or being told a close that has not happened had
finished. A close that fails clears it, so the next caller retries.

**CL7 asserts the concrete refusal** — the named prop and the source location —
rather than the absence of a provider message, which an unrelated failure would
also satisfy.
**The deadlock was mine, not the provider's.** A bounded reproduction — one
self-closing pane through `foregroundTerminalGrid()`, fake tmux, a real worker
on a real socket, and a shell fixture that signals its start and then stays —
traced the whole teardown in order the moment a hangup was actually delivered:
detach, worker settlement, holder-free goodbye, channels closed, server stopped.
The earlier row never delivered one. It passed `hangup` as a provider
dependency, which an earlier repair had removed, so the override was inert and
the run waited on a real SIGHUP that never came. No production change was needed
for it, and the instrumentation is gone.

**One real defect it did expose.** `useHangupCancellation` discarded what
`next(request)` returned, so every ordinary run through the installer was
refused for having "returned before the document produced a result". The result
is returned now, and `underHangup` is typed to carry it.

**TH4 is the host boundary, driven by the installed listener.** A real document
with a live grid, through everything `foregroundTerminalGrid()` installs, with
`process.kill(process.pid, "SIGHUP")` rather than a stand-in. It proves
cancellation rather than reader close, that the sibling after the grid never
ran, that the pane's child and every worker are gone, that the server is gone,
that the private directory — removed last, after its sockets close — is gone,
and that the SIGHUP listener went with the run that installed it. Every wait is
on an event: the pane child's own start file, and each worker's own exit.

Both halves of the installer are load-bearing: removing
`useHangupCancellation()` leaves TH4 hanging on a grid nothing ends, and
removing the provider registration fails it outright.

One thing recorded rather than asserted around: cancelling the document from
inside its own middleware surfaces as core's "middleware returned before the
document produced a result" rather than as `TerminalLost`, because the guard
fires on the cancelled canonical execution first. The observable contract holds
— the run fails, teardown completes, no sibling runs — so the row asserts those
and not the wording.
… handling (#732)

The teardown was the least-covered part of this provider, and covering it
found two defects.

A close *request* could fail outside the boundary that handled the waits.
`socket.destroy()` and `server.close()` were called after their closure
watch had been attached and queued, so a request that threw left a wait
nothing would ever settle — the whole close hung rather than failing. The
requests are now inside the same boundary, a watch whose request threw is
abandoned rather than awaited, and the handle stays out of the closed set so
a later call asks it again while leaving the ones that closed alone.

A retried teardown restarted rather than resumed. Every phase was re-asked,
so a worker that had already said goodbye and gone answered the second ask
as "a worker that was gone" — and that answer replaced the reason the first
attempt could not finish. The composite's own finalizer retries after a
failed `destroy()`, so this was the ordinary path: a document was told its
pane had vanished when what had actually happened was that the server would
not stop. Phases that succeeded are now remembered, and a retry resumes at
the one that failed.

The teardown itself moves out of the composite closure into
`createGridTeardown()`, which is what lets a row drive it with scripted
workers over real private sockets.

Rows: TH5 freezes the ordinary foreground-host branch — the same live grid
as TH4, ended by a reader detach through the fake control channel instead of
a hangup, asserting the exact result handed back through
`useHangupCancellation()`. It fails if either the tmux provider or the POSIX
observer is removed from `foregroundTerminalGrid()`. TH6 freezes entrypoint
selection. Tier TD covers the combined teardown: shared in-flight teardown
under concurrent destroys, the three protocol refusals, one pane's failure
stranding neither the next pane nor the channels nor the server, first-
failure preservation, the frozen order through to path removal, the retried
close request, the resumed retry, and the document-level refusal.

Real terminal restoration remains #726's real-tmux evidence.
…732)

The suite hung forever under Node and Bun. Not failed — hung, which leaves a
runtime shard running until the job's own timeout with nothing to read.

A pane's worker is this executable re-invoked under the hidden
`terminal-worker` subcommand, and only the hosts that present grids register
it: the Deno entrypoint and the compiled binary. On Node and Bun the same
argument vector names a *document* called `terminal-worker`, so the worker
exits with ENOENT before it connects and the parent waits for a pane that
will never say hello. It stalls entering TW3, the first row that spawns a
real worker.

    $ tsx packages/cli/src/node.ts terminal-worker 0 <dir>
    ENOENT: no such file or directory, open 'terminal-worker'

That Node and Bun install no grid provider is the design, so the fix is the
exclusion this repository already has a mechanism for rather than a portable
worker. Every other test file in this stack runs under Node unchanged; this
is the only one that cannot.

What the exclusion does and does not preserve, stated precisely because the
rationale is the reason a later reader would trust it: provider absence is
covered portably by TG9 in packages/core/tests/terminal-grid.test.ts, which
runs on all three runtimes. TH6's entrypoint-selection freeze is textual, so
proving it once under Deno proves it everywhere. TH3 makes the same claim as
TG9 but is excluded with the rest of the file and proves nothing here. What
is genuinely Deno-only is the worker, socket and fake-tmux integration.
DEC-016, as amended by 630c3f0a. The lifecycle and the one provider that
implements it were spread across runtime, core and CLI, which made a second
presentation provider depend on CLI internals and made the neutral authority
look core-specific. They are now two ordinary workspace members:

- `@executablemd/terminal` — native launch routing and reservation, grid and
  pane requests, composites and states, `TerminalGrids`/`TerminalProviders`,
  registration and direct authority, claims and readiness, row-major layout,
  the live and durable grid, pane routing, retained outcomes, the process
  observation contract and quiescence, the POSIX adapters, and the controlled
  fixtures — as root, `./lifecycle`, `./processes`, `./posix` and `./test`.
- `@executablemd/terminal-tmux` — probing and commands, the hidden server and
  control clients, layout and swaps, attach, the authenticated channels and
  their protocol, the worker and its child, and the one ordered teardown.

The arrows point at the neutral domain: terminal imports no runtime, core,
CLI or tmux; terminal-tmux imports terminal and nothing above it; core
imports terminal; runtime has no terminal edge at all; only CLI composes all
four. `packages/cli/src/terminal/` is gone — the host composition that
remains CLI's is `grid-host.ts`, which chooses and installs a provider rather
than implementing one.

Ownership moves, behavior does not. The stack has not merged, so the terminal
exports that sat in runtime and core were never a compatibility surface —
they were the ambiguity this removes. They are deleted rather than forwarded,
every repository import names a canonical package surface, and no alias,
barrel or wrapper keeps an old path reachable. Authored syntax, the provider
name, the hidden worker verb, the protocol, durable records, diagnostics and
every provider identity are untouched.

TG21 is the evidence, and each half was probed by breaking it. The dependency
rows read production imports from source rather than trusting a manifest, and
fail when a `@executablemd/core` import is planted in the neutral package.
The absence rows fail when a runtime terminal export is re-added or the old
CLI path is recreated. The uniqueness row replaces what object-identity used
to prove: with one import path left, the claim worth making is that there is
only one definition to reach, so a second `createApi` or class cannot quietly
split middleware composition between two objects that behave alike. A
non-vacuity row keeps the absence claims from passing over an empty walk.

Two things the work found rather than assumed. The fixture that runs the
stand-in tmux client resolved its program through a repo-relative string;
the move left it pointing at nothing, which started no client — and TG13,
whose subject is a client that refuses to leave, passed anyway, because one
that never starts never leaves either. It now resolves from its own module
URL. And the uniqueness scan first keyed on names ending in `Error`, which
reported `TerminalTeardownFailed` as having no definition at all; it matches
any exported class now.

Claude-Session: https://claude.ai/code/session_01CrKBYDBanPrxDqdQFgvFwS
… state (#717)

Three Architect blockers on 3c10bc59.

**The root was a facade.** `@executablemd/terminal`'s root re-exported a
handful of neutral names out of modules that also spawned children and carried
test doubles, so importing the domain loaded `node:child_process`,
`node:process` and a fixture. Selective re-export narrows what is reachable by
name and nothing about what is loaded. `launcher.ts` and `terminal.ts` are now
five modules: `native-launcher.ts` and `composite.ts` hold the contracts and
import no host API, `posix-launcher.ts` holds the foreground child, and
`controlled-launcher.ts`/`controlled-composite.ts` hold the fixtures. Root,
`./lifecycle` and `./processes` load none of the latter three; `./posix` and
`./test` are where that code lives. Each descriptor and error still has exactly
one definition — the split moved implementations, it declared nothing twice.

**The tmux root was wider than its accepted API.** `paneEnvironment` decided
which of *this invocation's* environment variables a pane inherits, which is a
host's decision and not the adapter's; the adapter only passes along whatever
`TmuxProviderDependencies.env` it is handed. It moves to CLI host composition
beside its single caller, with the same allowlist, the same order and the same
`TERM` default, so no pane's environment changes.

**The lock state was incomplete.** The previous commit added two workspace
members but only ran the Deno install, so `pnpm-lock.yaml` and `bun.lock` had
no importer for either. Repaired through the documented procedure —
`deno install --frozen=false`, then `deno task setup`, then `bun install`.
Both locks now carry `packages/terminal` and `packages/terminal-tmux` and the
five direct edges to terminal (acp, cli, core, terminal-tmux, test-agent);
runtime has none, in its sources, its manifest and both locks.
`publish-packages.yml` regenerates byte-identical, so the committed copy is
already correct rather than corrected here.

TG21 gains three rows for what the findings exposed. TG21i reads each
entrypoint's transitive module graph rather than its export list — the facade
passed an export check and fails this one — and TG21j is its discriminator, so
the absence claim cannot pass over a graph emptied by deletion. TG21k pins the
tmux root as an exact set rather than a set of required names, because
`paneEnvironment` reached that root by being added to it and a required-names
check would have let it stay. Probed: re-exporting the POSIX launcher from the
root fails TG21i; adding one name to the tmux root fails TG21k.

Claude-Session: https://claude.ai/code/session_01CrKBYDBanPrxDqdQFgvFwS
`deno task check:jsr` failed at 5163aa0c with five `missing-explicit-type`
errors in `packages/terminal-tmux/src/pane-protocol.ts`. The extraction caused
it: that module was internal to CLI and reached no published entrypoint, and it
is now part of a published package, where JSR forbids slow types. An inferred
zod type has no explicit form to publish.

Hiding the re-export was not enough — JSR follows references out of the public
API, and the exported `z.infer` aliases pulled the schemas back in. So the
frames are declared instead: `Hello`, `Swept`, `Settlement`, `FromWorker` and
`ToWorker` are written out, and each schema is held to its frame by a
`z.ZodType<T>` binding. That is compile-enforced rather than a convention —
changing `pid` to `z.string()` fails the typecheck at the binding, which is how
I checked it rather than assuming.

The schemas themselves become private. What crosses the package boundary is
`parseFromWorker`/`parseToWorker`: a caller — including this adapter's own
tests — needs "turn these bytes into a frame or throw", not the shape of the
validator. The two internal callers and the one test use them now.

No wire format changed. The declared frames are exactly what the schemas
already produced, which is what the bindings assert, so the protocol, the
worker grammar and every diagnostic are byte-identical.

Claude-Session: https://claude.ai/code/session_01CrKBYDBanPrxDqdQFgvFwS
`@effectionx/process` writes every child's stdout and stderr straight to the
host process — that is `Stdio`'s documented default — and consuming a stream
does not turn it off, because the two are independent. The hidden `tmux -C`
client's stdout *is* the control protocol, so `%session-changed`,
`%window-renamed`, `%window-pane-changed` and every other record was being
drawn on the reader's terminal and over pane prompts. Nothing about the grid
looked wrong; the terminal just had protocol on it.

The repair is one per-process suppression on the client the provider owns: a
`stdout` handler that never calls `next`, which is how this repository already
suppresses that default (`scripts/verify.ts`). The stream is still consumed and
classified exactly as before, so no event, ordering or diagnostic changes.

stderr is deliberately left alone. The two streams mean different things here —
stdout is the protocol, stderr is the client saying something went wrong — and
silencing both would make a grid that failed fail quietly.

TG14 and TG15 are the pair, and each was checked against the other. TG14
watches this process's own stdout while the composite consumes four records,
and fails on the unrepaired provider — it is a reproduction before it is a
regression test. It asserts on the `%` records rather than on the exact lines
this suite cares about, so it covers the protocol and not four strings. TG15
drives the client's stderr through a new fixture directive and proves the
complaint still arrives; suppressing stderr as well makes TG15 fail, which is
what says the repair is the minimal one rather than merely a working one.

Adjacent, reported rather than changed: `tmux.ts` runs its commands through the
same default with `.join()`, so real tmux command output would leak the same
way. That is not reproducible under the fake tmux this suite uses, and no gate
exposed it, so it is left for the Architect to direct.

Claude-Session: https://claude.ai/code/session_01CrKBYDBanPrxDqdQFgvFwS
Every process this provider starts for itself now runs inside `quietly()`,
which installs `Stdio.around` in the owning Effection scope before the child
starts: the hidden `tmux -C` watcher, `tmuxAt().run()`, `tmuxAt().tryRun()`,
and the default `tmux -V` probe. Both streams are suppressed. The visible
attach client and every pane child are untouched — they inherit the terminal
deliberately, and the scope is a child scope so the suppression reaches
neither.

Nothing about parsing changes. `client.stdout` is still read, split and
classified exactly as before, and `run`/`tryRun` still parse the captured
result; what is suppressed is forwarding to the host, which is a separate
thing from the stream. Lifecycle ordering, provider identity, the protocol and
the public errors are all as they were.

Raw tmux stderr is never forwarded. A soft `tryRun` failure is still
`undefined`, and a hard one is still `TmuxCommandFailed` naming the step and
nothing else — no socket, session or pane, which is what tmux's own complaint
would have carried.

Five rows, at the boundary that matters: what this process writes to its own
streams. TG14 covers the control records, TG15 that a complaining client stays
silent to the reader (replacing its previous raw-stderr requirement, which the
ruling reverses), TG16 the first record on attach, TG17 internal commands —
success, soft failure and hard failure — through a `tmux` on PATH rather than
the machine's, and TG18 a whole grid's life from startup through pane
switching, detach, server disappearance and teardown, asserting no record and
no private metadata. Every step is driven by a classified event; no row waits
on a duration.

Two things measured rather than assumed. Removing the suppression fails all
five rows, so they discriminate its absence. But installing it on the handle
after `exec()` returns still passes every row, including TG16 with a shell
client that writes within a millisecond — the parent installs the handler
before the child is ever scheduled, so the race the pre-spawn placement
protects against does not occur here. The placement is still the one that
cannot lose that race by construction, which is what the code and TG16 now
say; what I could not do is produce a row that fails without it, and I am not
claiming otherwise.
…717)

The full Deno corpus failed at 319cad11: 914 passed, 1 failed, and the failure
was `scripts/tests/test-file-discovery.test.ts` reporting two files the corpus
could see that discovery could not —

    + "packages/terminal-tmux/test.ts"
    + "packages/terminal/test.ts"

Deno's own test-file pattern matches a bare `test.ts`, so both packages'
controlled-fixture entrypoints were test files as far as the runner was
concerned: it would load them in every shard, and they sit outside the
discovery that assigns the corpus to runtimes. That is the invariant that guard
exists for, and the extraction introduced it by naming an entrypoint `test.ts`.

The export specifier is what the architecture fixes — `@executablemd/terminal/test`
and `@executablemd/terminal-tmux/test` — not the filename behind it. So the
files become `testing.ts` and the `./test` exports point at them. No consumer
changes: every importer already used the specifier, and the only references to
the old filenames were the two manifests. TG21j reads an entrypoint by name and
follows.

Both files say why they are named that way, because the obvious tidy-up is to
rename them back.

No new row: `test-file-discovery` is the repository-wide invariant for exactly
this, it caught this, and it passes now. A second copy of it next to the
packages would be a duplicate rather than evidence.
Bun shard 2 failed at fd5f28cf on TG21h: `NativeLauncher` was reported as
having eight definitions rather than one, each of them the same file —

    terminal/src/native-launcher.ts
    terminal-tmux/node_modules/@executablemd/terminal/src/native-launcher.ts
    acp/node_modules/@executablemd/terminal/src/native-launcher.ts
    …

A workspace install links every dependency package under its dependents, so
one source file is reachable at many paths. My walkers did not prune
`node_modules`, and Bun's layout creates those links where Deno's does not —
so every row in this tier had been passing under one runtime for a reason that
does not hold under the other.

The count was the visible failure; the quieter one is `importsOf()`. It read a
vendored copy's imports as if they were the importing package's own, which
means the dependency rows — the ones that say terminal reaches no engine, host
or provider — were scanning code that belongs to terminal's *dependents* and
its own dependencies. They passed, but not for the reason they claim to.

Both walkers now skip any path segment naming an installed or generated tree,
which is the same pruning `scripts/tests/test-file-discovery.test.ts` does for
the same reason.

TG21l is the discriminator, asserting no walked file sits under `node_modules`
for four packages and repository-wide. Disabling the pruning fails TG21l and
TG21h; restoring it passes both, under Deno and under Bun.

Production code is untouched. This is a defect in the evidence I wrote, found
by a runtime whose install layout differs — which is the argument for running
the shards rather than trusting one runtime's result.
Preserve the host's non-empty COLORTERM in the restricted pane environment, and preserve an omitted native-launch environment through the private worker protocol so the child inherits that environment. Explicit environment maps remain exact.
* 📝 Name interactive presentation as Grid and Pane (#781)

* 🚚 Rename the interactive presentation domain to Grid and Pane (#781)

`Terminal.Grid` and `<Terminal>` named a whole presentation after the terminal that one kind of pane happens to need. The authored syntax is now `<Grid>` and `<Pane>`, the provider-neutral domain ships as `@executablemd/grid`, and the tmux provider as `@executablemd/grid-tmux`. Core's adapters move to `packages/core/src/grid`.

Public symbols follow one rule: grid orchestration is `Grid*`, authored children and claims are `Pane*`, provider APIs are `GridProvider*`. `TERMINAL_GRIDS_API` becomes `GRIDS_API` under the stable name `Grids`, and `TERMINAL_PROVIDERS_API` becomes `GRID_PROVIDERS_API` under `GridProviders`.

Terminal stays where it names a real capability. `NO_TERMINAL`, `reserveTerminal`, `NativeLauncher`, `PaneTerminal`, `TerminalProcesses`, `TerminalSignal` and `TerminalLost` are unchanged, as are the tmux provider identity, the hidden `terminal-worker` verb, the durable event kind `terminal_grid_layout` and the durable identity `terminal_grid:${path}:layout`. Nothing a journal already holds is invalidated.

No compatibility package, re-export, alias, forwarding barrel or old implementation directory remains, and the generated publication workflow places grid after durable-streams, grid-tmux and core after grid, and CLI after all of them.
@taras
taras force-pushed the agent/issue-717-terminal-packages branch from 3d5a555 to e4bb6b8 Compare September 11, 2026 01:29

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 1 redundant comment. Inline suggestions to remove them below.

Comment thread packages/cli/src/cli.ts
// gets no machine sessions at all, which is the ordinary ACP behaviour.
sessions?: MachineSessionAssembly,
// What presents a terminal grid on this host. Deno and the compiled binary
// What presents a grid on this host. Deno and the compiled binary

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// What presents a grid on this host. Deno and the compiled binary

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant