Skip to content

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

Open
taras wants to merge 10 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 10 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

Why

Terminal-grid execution was implemented across core, runtime, and CLI modules,
which made the provider-neutral lifecycle difficult to reuse without also
depending on a concrete tmux host. This change gives the terminal domain and
the tmux presentation adapter explicit workspace boundaries.

What changes

Before:

  • terminal authority, lifecycle, native launching, process observation, and
    tmux presentation lived inside the packages that first consumed them;
  • importing the neutral terminal contract could pull host-specific code into a
    consumer;
  • the extracted package briefly exposed compatibility re-exports that were not
    needed because this stack has not shipped.

After:

  • @executablemd/terminal owns provider-neutral terminal contracts, lifecycle,
    authority, layout, native-launch routing, and controlled test support;
  • @executablemd/terminal/posix is the explicit host facet for POSIX launching
    and process observation;
  • @executablemd/terminal-tmux owns the tmux provider, private pane protocol,
    worker, layout, and teardown;
  • core, CLI, and TestAgent depend on the narrow facet they use, with no legacy
    compatibility exports.

How it works

document structure → @executablemd/terminal lifecycle
                   → selected host facet/provider
                   → @executablemd/terminal-tmux on Deno and compiled hosts

The tmux worker protocol is declared at the adapter boundary so the package can
be published and consumed without reaching back into CLI internals. Pane child
stdio is inherited from the terminal it draws on, while hidden tmux control
processes keep their own output off the reader's terminal.

Review guide

Start with: packages/terminal/tests/package-boundary.test.ts

Then review:

  1. packages/terminal/mod.ts and its explicit POSIX/test facets
  2. packages/terminal-tmux/mod.ts and its private protocol ownership
  3. core, CLI, and TestAgent imports at the new package boundaries
  4. workspace manifests, locks, release publication, and runtime exclusions

Look carefully at:

  • provider-neutral imports do not load POSIX or tmux implementation modules;
  • no compatibility re-export remains for the unshipped old locations;
  • event listeners and child processes remain owned by the Effection scope that
    installed them.

What must stay true

  • Terminal structure and lifecycle remain provider-neutral — enforced by the
    package graph and checked by the package-boundary suite.
  • Tmux remains a replaceable Deno/compiled host adapter — checked by the host
    and tmux integration suites.
  • Native launches inside panes retain exact argv, cwd, environment, readiness,
    cancellation, and quiescence behavior.
  • Node and Bun continue to validate the language while refusing operational
    terminal grids from provider absence.

How to verify it

  • deno task test packages/terminal/tests/package-boundary.test.ts checks the
    public/facet boundary and rejects imports that cross it.
  • deno task test packages/terminal/tests/ packages/terminal-tmux/tests/
    exercises neutral lifecycle, POSIX launching, process observation, tmux IPC,
    pane execution, and teardown.
  • deno task check and deno task lint pass on the rebased stack.

Scope

Included

  • terminal and tmux workspace packages
  • explicit neutral, POSIX, and test facets
  • consumer import migration
  • package publication and lock metadata
  • adapter protocol, fixtures, and package-boundary evidence

Intentionally unchanged

  • authored <Terminal.Grid> and <Terminal> syntax
  • the accepted terminal-grid lifecycle and tmux behavior
  • Agent-session identity, coordinator, and native-capability policy

New abstractions

  • @executablemd/terminal exists so document/runtime consumers can share one
    neutral lifecycle without importing a presentation provider.
  • @executablemd/terminal-tmux exists so tmux is one replaceable host adapter,
    not part of the document or core contract.
  • Each new abstraction has concrete production and test consumers.
  • No speculative compatibility surface is included.

Generated or mechanical changes

  • Workspace lock importers and release publication entries follow the new
    package graph.
  • Import moves preserve the accepted behavior; package-boundary tests own the
    resulting module graph.

Risks and limitations

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

taras added 10 commits September 7, 2026 04:42
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.
@taras
taras marked this pull request as ready for review September 7, 2026 11:51
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