♻️ Extract terminal providers into workspace packages (#717) - #771
Open
taras wants to merge 10 commits into
Open
♻️ Extract terminal providers into workspace packages (#717)#771taras wants to merge 10 commits into
taras wants to merge 10 commits into
Conversation
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.
4 tasks
taras
marked this pull request as ready for review
September 7, 2026 11:51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
tmux presentation lived inside the packages that first consumed them;
consumer;
needed because this stack has not shipped.
After:
@executablemd/terminalowns provider-neutral terminal contracts, lifecycle,authority, layout, native-launch routing, and controlled test support;
@executablemd/terminal/posixis the explicit host facet for POSIX launchingand process observation;
@executablemd/terminal-tmuxowns the tmux provider, private pane protocol,worker, layout, and teardown;
compatibility exports.
How it works
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.tsThen review:
packages/terminal/mod.tsand its explicit POSIX/test facetspackages/terminal-tmux/mod.tsand its private protocol ownershipLook carefully at:
installed them.
What must stay true
package graph and checked by the package-boundary suite.
and tmux integration suites.
cancellation, and quiescence behavior.
terminal grids from provider absence.
How to verify it
deno task test packages/terminal/tests/package-boundary.test.tschecks thepublic/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 checkanddeno task lintpass on the rebased stack.Scope
Included
Intentionally unchanged
<Terminal.Grid>and<Terminal>syntaxNew abstractions
@executablemd/terminalexists so document/runtime consumers can share oneneutral lifecycle without importing a presentation provider.
@executablemd/terminal-tmuxexists so tmux is one replaceable host adapter,not part of the document or core contract.
Generated or mechanical changes
package graph.
resulting module graph.
Risks and limitations
automated adapter tests use fake tmux with real workers and private sockets.
verified.
Scope confirmation