diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 9f56a1e9e..c7918cb0e 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -30,7 +30,7 @@ jobs: - name: Validate the manifests declare this version run: | VERSION="${{ steps.resolve.outputs.value }}" - for f in packages/durable-streams/deno.json packages/runtime/deno.json packages/core/deno.json packages/acp/deno.json packages/testing/deno.json packages/test-agent/deno.json packages/web/deno.json packages/workflow/deno.json packages/cli/deno.json packages/code-review-agent/deno.json; do + for f in packages/durable-streams/deno.json packages/runtime/deno.json packages/terminal/deno.json packages/core/deno.json packages/acp/deno.json packages/terminal-tmux/deno.json packages/testing/deno.json packages/test-agent/deno.json packages/web/deno.json packages/workflow/deno.json packages/cli/deno.json packages/code-review-agent/deno.json; do declared="$(jq -r .version "$f")" if [ "$declared" != "$VERSION" ]; then echo "::error::$f declares $declared, not $VERSION — the tag does not match the manifests" @@ -75,20 +75,34 @@ jobs: package: packages/runtime version: ${{ needs.version.outputs.value }} + terminal: + needs: [version, durable-streams] + uses: ./.github/workflows/publish-one.yml + with: + package: packages/terminal + version: ${{ needs.version.outputs.value }} + core: - needs: [version, durable-streams, runtime] + needs: [version, durable-streams, runtime, terminal] uses: ./.github/workflows/publish-one.yml with: package: packages/core version: ${{ needs.version.outputs.value }} acp: - needs: [version, core, runtime] + needs: [version, core, runtime, terminal] uses: ./.github/workflows/publish-one.yml with: package: packages/acp version: ${{ needs.version.outputs.value }} + terminal-tmux: + needs: [version, terminal] + uses: ./.github/workflows/publish-one.yml + with: + package: packages/terminal-tmux + version: ${{ needs.version.outputs.value }} + testing: needs: [version, core, durable-streams, runtime] uses: ./.github/workflows/publish-one.yml @@ -97,7 +111,7 @@ jobs: version: ${{ needs.version.outputs.value }} test-agent: - needs: [version, acp, core, durable-streams, runtime, testing] + needs: [version, acp, core, durable-streams, runtime, terminal, testing] uses: ./.github/workflows/publish-one.yml with: package: packages/test-agent @@ -118,7 +132,7 @@ jobs: version: ${{ needs.version.outputs.value }} cli: - needs: [version, acp, core, durable-streams, runtime, test-agent, testing, web, workflow] + needs: [version, acp, core, durable-streams, runtime, terminal, terminal-tmux, test-agent, testing, web, workflow] uses: ./.github/workflows/publish-one.yml with: package: packages/cli diff --git a/architecture.md b/architecture.md index 6b4c0a476..90e980cac 100644 --- a/architecture.md +++ b/architecture.md @@ -2931,6 +2931,162 @@ outside that pane. Each pane also owns its checked-failure ledger. A checked failure settles that pane without poisoning the root or a sibling; core alone observes the pane outcome and applies the grid's settlement rule after close. +### Package ownership + +The terminal domain is independent of both the document engine that invokes it +and the presentation provider that implements it. Two publishable workspace +packages make those boundaries explicit: + +- `@executablemd/terminal` owns native foreground-launch routing and + reservation; provider-neutral grid and pane requests, composites, states, + errors, and row-major layout; `TerminalGrids` and `TerminalProviders` routing; + provider registration and direct authority delivery; grid and pane claims, + readiness, stale-authority refusal, lifecycle, reader-close settlement, + retained outcomes, and replay; pane-scoped launch routing; the + `TerminalProcesses` observation contract and quiescence operations; and the + controlled launcher, composite, and log fixtures used to prove the contract. +- `@executablemd/terminal-tmux` owns tmux capability probing and commands, the + hidden server and control clients, explicit layout and pane swaps, visible + attach, authenticated Unix-socket channels and their protocol, the persistent + pane worker and its child, worker invocation, and the provider's one ordered + teardown. No tmux command, type, identifier, protocol value, or host probe is + part of the neutral package. + +`@executablemd/terminal` exports its ordinary domain surface from the package +root. Its `./lifecycle` entrypoint exports authority creation, provider +installation, claims, readiness, grid execution, retained outcomes, and the +reader-close boundary. Its `./processes` entrypoint exports +`TerminalProcesses`, process facts and signals, snapshots, and quiescence. Its +`./posix` entrypoint exports the POSIX process and terminal probes and the +foreground-child adapter. Its `./test` entrypoint exports only controlled +launchers, composites, logs, and signals. These entrypoints are facets of one +package, not independent definitions: anything exported from more than one is +the same object. + +`@executablemd/terminal-tmux` exports `TMUX_PROVIDER`, +`TmuxProviderDependencies`, `tmuxGridProvider`, `installTmuxGridProvider`, the +unchanged `PANE_WORKER_COMMAND`, the hidden pane-worker invocation parser, the +pane-worker process runner, and the provider's documented refusal errors from +its root. Protocol frames, channel +handles, tmux process wrappers, layout mechanics, and teardown hooks stay +private. Controlled low-level seams needed by the adapter's own tests are +available only from its `./test` entrypoint and are not a second provider API. + +The dependency graph points toward the neutral domain: + +```text +@executablemd/terminal-tmux ──> @executablemd/terminal +@executablemd/core ──> @executablemd/terminal +@executablemd/cli ──> core + runtime + terminal + terminal-tmux +``` + +The terminal package may depend on durable streams, Effection, and EffectionX; +it never imports runtime, core, CLI, or terminal-tmux. The tmux package never +imports runtime, core, or CLI. Moving the native-launch descriptor into the +neutral package is load-bearing: leaving it in runtime would either reverse the +domain dependency or make terminal depend on runtime. Core remains the owner of +Markdown parse and expansion, `SourcePosition` journal descriptions, +execution-profile installation, and Agent session behavior. Its +`src/terminal/journal.ts` and `src/terminal/profile.ts` therefore stay in core; +the neutral authority, provider API, layout, grid lifecycle, pane claim, and +pane-launcher modules move. Runtime's launcher, terminal composite, process +observation, and POSIX observer modules move. CLI's attach client, tmux layout, +pane channel, child, protocol and worker, provider, grid, and tmux command +modules move to terminal-tmux; CLI retains only entrypoint and execution +composition. + +The extraction applies to the current modules as follows: + +| Current module | Destination and responsibility | +|---|---| +| `packages/runtime/launcher.ts` | Split between terminal's neutral root, POSIX foreground-child adapter, and controlled test entrypoint | +| `packages/runtime/terminal.ts` | Split between terminal's neutral root and controlled test entrypoint | +| `packages/runtime/terminal-processes.ts` | `@executablemd/terminal/processes` | +| `packages/runtime/deno-terminal-processes.ts` | `@executablemd/terminal/posix`; delete the old module after moving it | +| `packages/core/src/terminal/authority.ts` | `@executablemd/terminal/lifecycle` | +| `packages/core/src/terminal/provider-api.ts` | Terminal root and lifecycle entrypoints | +| `packages/core/src/terminal/grid.ts` | `@executablemd/terminal/lifecycle` | +| `packages/core/src/terminal/pane-launcher.ts` and `pane.ts` | Terminal's neutral pane and launcher surface | +| `packages/core/src/terminal-grid.ts` | Split so neutral layout and grid lifecycle move to terminal while authored element scanning, expansion and source integration stay in core | +| `packages/core/src/terminal/journal.ts` and `profile.ts` | Stay in core; they adapt terminal lifecycle to core journal descriptions and `Execution` | +| `packages/cli/src/terminal/{attach-client,layout,pane-channel,pane-child,pane-protocol,pane-worker,provider,tmux-grid,tmux}.ts` | Move to `@executablemd/terminal-tmux` | +| `packages/cli/src/terminal/host.ts` | Split: reusable provider and POSIX pieces move to their packages; the core `Execution` wrapper and entrypoint composition stay in a genuinely non-terminal CLI module, and the old terminal path is deleted | + +Tests follow the code whose contract they prove: neutral routing, authority, +layout, lifecycle, replay and process-quiescence suites live under terminal; +tmux topology, protocol, worker, host-process and teardown suites live under +terminal-tmux; syntax, source integration and durable journal descriptions stay +under core; cross-package Agent composition stays with test-agent; entrypoint +selection and compiled-host evidence stay under CLI. + +The former `@executablemd/runtime` and `@executablemd/core` terminal exports and +old `packages/cli/src/terminal` implementation paths are deleted. This stack is +unmerged, so none is a compatibility surface. Every repository consumer imports +the canonical terminal or terminal-tmux package entrypoint, and no forwarding +barrel or alias preserves an old path. Each contextual API and error constructor +therefore has one canonical definition and import path; stable contextual API +names and `instanceof` behavior remain unchanged within that surface. + +POSIX process-table, process-group, signal, reachability, and terminal-holder +observation lives behind `@executablemd/terminal/posix`, not in the tmux +adapter. A different POSIX presentation provider can reuse the same proof +without depending on tmux. The Deno and compiled CLI entrypoints remain the +host-composition boundary: they choose tmux, resolve self-reinvocation, terminal +size and environment, translate host `SIGHUP` into structured cancellation, and +install the POSIX observer both in the supervising run and inside each pane +worker because contextual state does not cross a process boundary. Node and Bun +continue to install neither observer nor grid provider. + +This extraction changes ownership, not behavior. It preserves the authored +syntax, provider name `tmux`, hidden worker verb `terminal-worker`, worker +protocol and authentication, durable records and identities, diagnostic text +and normalization, readiness, close and replay semantics, and every provider +identity. Event registrations remain owned by the Effection scope whose +resource they observe and are removed when that scope settles. Both packages +are ordinary lockstep-versioned workspace members. The +generated publication graph places terminal after durable-streams, +terminal-tmux and core after terminal, and CLI after terminal-tmux, terminal, +core, and runtime. Runtime remains independent of terminal. Workspace, +JSR, npm, compiled-host, and runtime-test discovery treat them like every other +publishable package. + +The final extraction story is complete when this finite evidence passes: + +1. A static dependency test walks production imports and proves the four arrows + above, including the absence of terminal-to-runtime/core/CLI/tmux and + terminal-tmux-to-runtime/core/CLI edges. +2. A package-boundary test proves the old runtime, core, and CLI terminal paths + and exports are absent, every repository terminal import uses a canonical + package surface, and each public contextual descriptor and error constructor + has one definition. +3. Relocated neutral tests prove foreground launching, provider routing and + direct authority, claims and readiness, layout, close/cancellation/replay, + process observation, and quiescence without tmux. +4. Core tests prove the unchanged grammar, structural validation, source + diagnostics, pane scope, durable identities and records, retained outcomes, + and provider-neutral replay. +5. Terminal-tmux tests prove exact authenticated worker transport, concurrent + panes, sequential reuse, spawn readiness, display isolation, job control, + explicit row-major layout, atomic attach, the three close signals, SIGHUP, + scope-owned event registration, cancellation phases, and ordered bounded + teardown with real workers and sockets under the existing fake-tmux host. +6. The cross-package test Agent proves a pane-native launch reaches its physical + endpoint while root launch and natural-key Agent session ownership remain + unchanged. +7. CLI evidence proves Deno and compiled hosts select tmux and dispatch the + hidden worker with POSIX observation in both processes; Node, Bun, non-TTY, + and missing-tmux paths install no partial provider and retain their exact + refusals. +8. Workspace and release evidence proves discovery of both packages, valid + runtime exclusions, freshly measured corpus weights, generated dependency + order, JSR publishability, a local-sibling npm CLI build, the compiled binary + and hidden worker, and dependency-state cleanliness. + +Tests use controlled signals and observable settlement for lifecycle success; +elapsed time is not evidence. The focused feedback commit runs the smallest +explicit tests that discriminate these boundaries. Runtime-wide matrices, +lint, typecheck, JSR and clean composability remain delivery gates. + ### Terminal authority One grid holds the execution's foreground-terminal lease for its whole visible @@ -4489,7 +4645,7 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start; DEC-016 specifies the final behavior-preserving extraction into `@executablemd/terminal` and `@executablemd/terminal-tmux`, with every repository import moved to the canonical packages and the unshipped old terminal paths deleted | | native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | diff --git a/bun.lock b/bun.lock index 26a860d97..7980dedb1 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "mdast-util-to-string": "^4", "remark": "15", "remend": "^1.2.2", + "semver": "^7.8.5", "unist-util-select": "^5", "zod": "^4.3.6", }, @@ -42,6 +43,7 @@ "@executablemd/testing": "workspace:*", "@executablemd/workflow": "workspace:*", "@types/node": "^22.0.0", + "@types/semver": "^7.7.0", "expect": "^30.0.0", "oxfmt": "^0.41.0", "oxlint": "1.74.0", @@ -53,8 +55,10 @@ "name": "@executablemd/acp", "version": "0.11.0", "dependencies": { + "@agentclientprotocol/sdk": "1.3.0", "@executablemd/core": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "acpx": "0.12.0", "effection": "4.1.0", }, @@ -71,6 +75,8 @@ "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", + "@executablemd/terminal-tmux": "workspace:*", "@executablemd/test-agent": "workspace:*", "@executablemd/testing": "workspace:*", "@executablemd/web": "workspace:*", @@ -78,6 +84,7 @@ "@standard-schema/spec": "^1.0.0", "configliere": "^0.4.0", "effection": "4.1.0", + "semver": "^7.8.5", "zod": "^4.3.6", }, }, @@ -101,6 +108,7 @@ "@effectionx/timebox": "0.4.3", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@secretlint/core": "13.0.4", "@secretlint/profiler": "13.0.4", "@secretlint/secretlint-rule-preset-recommend": "13.0.4", @@ -137,6 +145,29 @@ "effection": "4.1.0", }, }, + "packages/terminal": { + "name": "@executablemd/terminal", + "version": "0.11.0", + "dependencies": { + "@effectionx/context-api": "0.6.0", + "@effectionx/fs": "0.3.0", + "@effectionx/node": "0.2.4", + "@effectionx/process": "0.8.1", + "@executablemd/durable-streams": "workspace:*", + "effection": "4.1.0", + }, + }, + "packages/terminal-tmux": { + "name": "@executablemd/terminal-tmux", + "version": "0.11.0", + "dependencies": { + "@effectionx/fs": "0.3.0", + "@effectionx/process": "0.8.1", + "@executablemd/terminal": "workspace:*", + "effection": "4.1.0", + "zod": "^4.3.6", + }, + }, "packages/test-agent": { "name": "@executablemd/test-agent", "version": "0.11.0", @@ -149,6 +180,7 @@ "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@executablemd/testing": "workspace:*", "acorn": "^8.16.0", "acpx": "0.12.0", @@ -160,9 +192,11 @@ "name": "@executablemd/test-support", "version": "0.0.0", "dependencies": { + "@effectionx/fs": "0.3.0", "@effectionx/process": "0.8.1", "@effectionx/test-adapter": "0.7.4", "@effectionx/timebox": "0.4.3", + "@executablemd/durable-streams": "workspace:*", "effection": "4.1.0", "expect": "^30.0.0", }, @@ -176,6 +210,7 @@ "@effectionx/timebox": "0.4.3", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", + "@executablemd/runtime": "workspace:*", "effection": "4.1.0", }, }, @@ -212,7 +247,6 @@ "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/fs": "0.3.0", - "@effectionx/process": "0.8.1", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", @@ -327,6 +361,10 @@ "@executablemd/runtime": ["@executablemd/runtime@workspace:packages/runtime"], + "@executablemd/terminal": ["@executablemd/terminal@workspace:packages/terminal"], + + "@executablemd/terminal-tmux": ["@executablemd/terminal-tmux@workspace:packages/terminal-tmux"], + "@executablemd/test-agent": ["@executablemd/test-agent@workspace:packages/test-agent"], "@executablemd/test-support": ["@executablemd/test-support@workspace:packages/test-support"], @@ -585,6 +623,8 @@ "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="], + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -937,6 +977,8 @@ "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "2.0.1", "kind-of": "6.0.3" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1053,6 +1095,8 @@ "@executablemd/durable-streams/@durable-streams/client": ["@durable-streams/client@0.2.6", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", "fastq": "^1.19.1" }, "bin": { "intent": "bin/intent.js" } }, "sha512-uHKKbWpsKLhFMeGjG0PgM6LXE3oEIi7FHKlJZkmYGxcqd4Yjjd/QEvnQnDzteRP4Av1uJVM8qjTL7kfKsgeS/w=="], + "@executablemd/terminal-tmux/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@executablemd/test-agent/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], diff --git a/deno.lock b/deno.lock index 7772922ea..f04adcbe3 100644 --- a/deno.lock +++ b/deno.lock @@ -4129,6 +4129,27 @@ ] } }, + "packages/terminal": { + "packageJson": { + "dependencies": [ + "npm:@effectionx/context-api@0.6.0", + "npm:@effectionx/fs@0.3.0", + "npm:@effectionx/node@0.2.4", + "npm:@effectionx/process@0.8.1", + "npm:effection@4.1.0" + ] + } + }, + "packages/terminal-tmux": { + "packageJson": { + "dependencies": [ + "npm:@effectionx/fs@0.3.0", + "npm:@effectionx/process@0.8.1", + "npm:effection@4.1.0", + "npm:zod@^4.3.6" + ] + } + }, "packages/test-agent": { "dependencies": [ "npm:@agentclientprotocol/sdk@1.3.0", diff --git a/packages/acp/package.json b/packages/acp/package.json index 5bd9f5d9e..4fc639685 100644 --- a/packages/acp/package.json +++ b/packages/acp/package.json @@ -11,6 +11,7 @@ "@agentclientprotocol/sdk": "1.3.0", "@executablemd/core": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "acpx": "0.12.0", "effection": "4.1.0" } diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index e6e39d709..6eb0597a5 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -88,8 +88,8 @@ import { AgentSessionRecoveryRequired, cwd, ExecutableObservationError, - nativeLaunch, } from "@executablemd/runtime"; +import { nativeLaunch } from "@executablemd/terminal"; import type { AgentSessionCoordinator, AgentSessionKey, diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 3984b860e..6cdf80860 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -27,13 +27,10 @@ import type { PreparedLaunchRecord, Session, } from "@executablemd/core"; -import { - flushOutput, - installControlledLauncher, - NativeLauncher, - reserveTerminal, -} from "@executablemd/runtime"; -import type { AgentSessionCoordinator, NativeLaunchRequest } from "@executablemd/runtime"; +import { flushOutput, NativeLauncher, reserveTerminal } from "@executablemd/terminal"; +import { installControlledLauncher } from "@executablemd/terminal/test"; +import type { AgentSessionCoordinator } from "@executablemd/runtime"; +import type { NativeLaunchRequest } from "@executablemd/terminal"; import { createAcpxProvider } from "../src/provider.ts"; import type { AcpxProviderDependencies } from "../src/provider.ts"; import { diff --git a/packages/cli/package.json b/packages/cli/package.json index c2d10da8a..242ef51a5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,6 +16,8 @@ "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", + "@executablemd/terminal-tmux": "workspace:*", "@executablemd/test-agent": "workspace:*", "@executablemd/testing": "workspace:*", "@executablemd/web": "workspace:*", diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index 6c855b87c..d754acf5b 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -21,9 +21,10 @@ import { registerAgentProvider, } from "@executablemd/core"; import type { AgentProviderFactory, PermissionMode } from "@executablemd/core"; -import { installForegroundLauncher, env as readEnv } from "@executablemd/runtime"; -import { unsupportedTerminalGrid } from "./terminal/host.ts"; -import type { TerminalGridInstaller } from "./terminal/host.ts"; +import { env as readEnv } from "@executablemd/runtime"; +import { installForegroundLauncher } from "@executablemd/terminal/posix"; +import { unsupportedTerminalGrid } from "./grid-host.ts"; +import type { TerminalGridInstaller } from "./grid-host.ts"; import { createAcpxProvider, DEFAULT_AGENT_NAME } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; // A separate entrypoint because the embedded adapters are temporary (#636) and diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 35dc9b579..9ad13d20a 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -96,8 +96,8 @@ import { installWebComponents, installWebElicitation } from "@executablemd/web"; import { timebox } from "@effectionx/timebox"; import { timeout as runTimeout } from "@executablemd/runtime"; import { installRunAgentStack, resolveAgentStack, resolveAuthorshipStack } from "./agent-stack.ts"; -import { unsupportedTerminalGrid } from "./terminal/host.ts"; -import type { TerminalGridInstaller } from "./terminal/host.ts"; +import { unsupportedTerminalGrid } from "./grid-host.ts"; +import type { TerminalGridInstaller } from "./grid-host.ts"; import { planComponentDeclaration } from "./plan-component.ts"; import { planAgentContext } from "./authorship-profile.ts"; import { useVerboseComponent } from "./verbose-component.ts"; diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index 0deb2ad58..c7d70d281 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -19,8 +19,8 @@ import { isCredentialHelperMode, runCredentialHelper, } from "@executablemd/workflow/credential-helper"; -import { paneWorkerInvocation, runPaneWorkerProcess } from "./terminal/pane-worker.ts"; -import { foregroundTerminalGrid } from "./terminal/host.ts"; +import { paneWorkerInvocation, runPaneWorkerProcess } from "@executablemd/terminal-tmux"; +import { foregroundTerminalGrid } from "./grid-host.ts"; import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; import { useCompiledService } from "./compiled-service.ts"; diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index dda509142..a1e7c9b5e 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -22,8 +22,8 @@ import { isCredentialHelperMode, runCredentialHelper, } from "@executablemd/workflow/credential-helper"; -import { paneWorkerInvocation, runPaneWorkerProcess } from "./terminal/pane-worker.ts"; -import { foregroundTerminalGrid } from "./terminal/host.ts"; +import { paneWorkerInvocation, runPaneWorkerProcess } from "@executablemd/terminal-tmux"; +import { foregroundTerminalGrid } from "./grid-host.ts"; import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; import { useDenoService } from "./deno-service.ts"; diff --git a/packages/cli/src/terminal/host.ts b/packages/cli/src/grid-host.ts similarity index 73% rename from packages/cli/src/terminal/host.ts rename to packages/cli/src/grid-host.ts index 4b942e804..591d773b3 100644 --- a/packages/cli/src/terminal/host.ts +++ b/packages/cli/src/grid-host.ts @@ -1,6 +1,11 @@ /** * Which hosts open a terminal grid, and which only describe one - * (architecture.md §Interactive terminal grids). + * (architecture.md §Package ownership). + * + * Host composition, not a terminal implementation — which is why it sits here + * rather than under a `terminal/` path. The domain is + * `@executablemd/terminal`'s and the provider is `@executablemd/terminal-tmux`'s; + * what this module does is decide, per entrypoint, whether to install them. * * The Deno source entrypoint and the compiled binary present grids when the * invocation has a terminal and a usable tmux. Node and Bun keep the same @@ -18,11 +23,14 @@ import { ensure, race, resource, withResolvers } from "effection"; import type { Operation } from "effection"; import process from "node:process"; import { Execution, installTerminalGridProfile } from "@executablemd/core"; -import { command as hostCommand, installDenoTerminalProcesses } from "@executablemd/runtime"; -import { installTmuxGridProvider, TMUX_PROVIDER } from "./provider.ts"; -import type { TmuxProviderDependencies } from "./provider.ts"; -import { paneEnvironment } from "./tmux.ts"; -import { PANE_WORKER_COMMAND } from "./pane-worker.ts"; +import { command as hostCommand } from "@executablemd/runtime"; +import { installDenoTerminalProcesses } from "@executablemd/terminal/posix"; +import { + installTmuxGridProvider, + PANE_WORKER_COMMAND, + TMUX_PROVIDER, +} from "@executablemd/terminal-tmux"; +import type { TmuxProviderDependencies } from "@executablemd/terminal-tmux"; /** How a host installs whatever presents its terminal grids. */ export type TerminalGridInstaller = () => Operation; @@ -95,6 +103,45 @@ export class TerminalLost extends Error { } } +/** + * The environment every process in the topology receives. + * + * Named rather than inherited wholesale: a pane's child gets what a terminal + * program needs and nothing this process happens to be carrying. + * + * It is a host decision, so it is made here rather than by the provider. The + * adapter is handed an environment and passes exactly that along; which of + * *this* invocation's variables are worth passing is a question only the + * entrypoint composing the host can answer. + */ +function paneEnvironment(source: Record): Record { + const env: Record = {}; + for (const name of [ + "PATH", + "HOME", + "SHELL", + "LANG", + "TMPDIR", + "USER", + "LOGNAME", + // What a terminal program reads to decide it may use 24-bit colour. + // Passed through when this host has it, absent when it does not: naming a + // capability the reader's terminal lacks is worse than leaving a program + // on the 256 colours `TERM` already promises. It is named here because a + // pane's direct child reads none of the reader's shell startup — a + // variable their `.zshrc` exports reaches an interactive shell in a pane + // and nothing else, which is exactly the difference this closes. + "COLORTERM", + ]) { + const value = source[name]; + if (value !== undefined && value !== "") { + env[name] = value; + } + } + env.TERM = source.TERM ?? "xterm-256color"; + return env; +} + /** The terminal this run is drawing on, as tmux needs to know it. */ function windowSize(): { columns: number; rows: number } { // A terminal that cannot say gets the sizes tmux itself defaults to, which is diff --git a/packages/cli/tests/agent-session-coordinator.test.ts b/packages/cli/tests/agent-session-coordinator.test.ts index 46464641c..7837bef20 100644 --- a/packages/cli/tests/agent-session-coordinator.test.ts +++ b/packages/cli/tests/agent-session-coordinator.test.ts @@ -28,8 +28,8 @@ import { API, createDenoAgentSessionCoordinator, hasDenoAgentSessionCoordinator, - installControlledLauncher, } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/terminal/test"; import type { AgentSessionCoordinator } from "@executablemd/runtime"; import { ADVERTISED_CLIENT_NATIVE_ATTACHMENT, @@ -39,7 +39,8 @@ import { createMemorySessionRouteStore, } from "@executablemd/acp"; import type { AgentSessionRouteStore, NativeAdapter, NativeBinding } from "@executablemd/acp"; -import type { ExecutableObserver, NativeLaunchRequest } from "@executablemd/runtime"; +import type { ExecutableObserver } from "@executablemd/runtime"; +import type { NativeLaunchRequest } from "@executablemd/terminal"; import { createFakeObserver } from "../../acp/tests/helpers.ts"; import { sessionCoordinatorRoot, diff --git a/packages/cli/tests/run-composition-deno.test.ts b/packages/cli/tests/run-composition-deno.test.ts index 5d512e000..8b47cf4e4 100644 --- a/packages/cli/tests/run-composition-deno.test.ts +++ b/packages/cli/tests/run-composition-deno.test.ts @@ -21,7 +21,8 @@ import { exists, readTextFile } from "@effectionx/fs"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; import process from "node:process"; -import { API, NativeLauncher, useHostFiles } from "@executablemd/runtime"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { NativeLauncher } from "@executablemd/terminal"; import { InMemoryStream } from "@executablemd/durable-streams"; import { Agent, diff --git a/packages/cli/tests/terminal-host.test.ts b/packages/cli/tests/terminal-host.test.ts new file mode 100644 index 000000000..a9e01686b --- /dev/null +++ b/packages/cli/tests/terminal-host.test.ts @@ -0,0 +1,603 @@ +/** + * Tier TH — which hosts open a terminal grid, and which only describe one + * (architecture.md §Package ownership, issue #717). + * + * The host-composition boundary is CLI's, so its evidence is too. The tmux + * adapter's own topology, protocol, worker and teardown rows live with the + * adapter in `@executablemd/terminal-tmux`; what is proved here is the part + * only an entrypoint can answer — which runtime installs a provider and an + * observer, which installs neither, what a real document gets in each case, + * and that a terminal going away cancels the run rather than closing the grid. + * + * The fake tmux server and its client fixtures are imported from the adapter's + * own tests. That is a test-only path: it creates no package dependency, and + * the production graph CLI declares is unchanged by it. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, Ok, resource, scoped, sleep, spawn, until, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import type { ChildProcess } from "node:child_process"; +import * as path from "node:path"; +import process from "node:process"; +import { chmod, readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { cliCommand } from "@executablemd/test-support/launch"; +import { ensureDir, exists, readTextFile, rm, writeTextFile } from "@effectionx/fs"; +import { execute } from "@executablemd/core"; +import { installTerminalProvider, useTerminalInstallation } from "@executablemd/terminal/lifecycle"; +import type { Json } from "@executablemd/core"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { registerTerminalProvider, TerminalGrids } from "@executablemd/terminal"; +import { installControlledLauncher } from "@executablemd/terminal/test"; +import { processReachable } from "@executablemd/terminal/processes"; +import { installDenoTerminalProcesses } from "@executablemd/terminal/posix"; +import { PANE_WORKER_COMMAND, tmuxGridProvider } from "@executablemd/terminal-tmux"; +import { foregroundSignalListeners } from "@executablemd/terminal-tmux/test"; +import { createFakeTmux } from "../../terminal-tmux/tests/fixtures/fake-tmux.ts"; +import { clientCommand } from "../../terminal-tmux/tests/fixtures/client-command.ts"; +import { foregroundTerminalGrid, unsupportedTerminalGrid } from "../src/grid-host.ts"; + +/** Where a fake server and its client fixtures meet. */ +function useScript(): Operation { + return resource(function* (provide) { + const file = path.join(tmpdir(), `xmd-tmux-script-${randomUUID()}.txt`); + yield* writeTextFile(file, ""); + yield* ensure(function* () { + yield* rm(file, { force: true }); + }); + yield* provide(file); + }); +} + +/** + * Open a grid through the provider, with the host's prerequisites answered by + * this row rather than by the machine. + * + * Goes through the real factory and the real installation handshake, so what a + * refusal proves is what a document would meet. + */ +function useProbedProvider(options: { + isTerminal: () => boolean; + version?: string; +}): Operation { + return (function* (): Operation { + const authority = yield* useTerminalInstallation(); + yield* registerTerminalProvider( + "tmux", + tmuxGridProvider({ + isTerminal: options.isTerminal, + env: { PATH: "/usr/bin:/bin" }, + // deno-lint-ignore require-yield + *workerCommand() { + return []; + }, + size: () => ({ columns: 80, rows: 24 }), + ...(options.version === undefined + ? {} + : { + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: options.version ?? "" }; + }, + }), + }), + ); + yield* installTerminalProvider("tmux", { label: "tmux" }, authority); + yield* TerminalGrids.operations.open({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], + }); + })(); +} + +/** A directory a row can leave markers in. */ +function useScratch(): Operation { + return resource(function* (provide) { + const room = path.join(tmpdir(), `xmd-tg20-${randomUUID()}`); + yield* ensureDir(room); + yield* ensure(function* () { + yield* rm(room, { recursive: true, force: true }); + }); + yield* provide(room); + }); +} + +/** Settle once this child has gone, whether or not it already had. */ +function exited(child: ChildProcess): Operation { + const done = withResolvers(); + const onExit = (): void => done.resolve(); + if (child.exitCode !== null || child.signalCode !== null) { + done.resolve(); + } else { + child.on("exit", onExit); + } + return (function* (): Operation { + try { + yield* done.operation; + } finally { + child.off("exit", onExit); + } + })(); +} + +/** A shell that says when it started, and stays until it is signalled. */ +function useShellFixture(room: string): Operation { + return resource(function* (provide) { + const file = path.join(room, "shell"); + yield* writeTextFile( + file, + [ + "#!/bin/sh", + // Its own environment, before anything else. A plain script sources no + // startup file, so what this records is what the pane handed it rather + // than what a `.zshrc` added afterwards. + `env > "${room}/shell-env"`, + `echo $$ > "${room}/shell-pid"`, + "while true; do sleep 0.05; done", + "", + ].join("\n"), + ); + yield* until(chmod(file, 0o755)); + yield* provide(file); + }); +} + +/** One entrypoint's source, for the rows about what a host assembles. */ +function entrypointSource(name: string): Operation { + return readTextFile(path.resolve("packages/cli/src", name)); +} + +describe("Tier TH — host installation", () => { + it("TD9: a teardown that fails refuses the run, and nothing after the grid goes", function* () { + // The document-level end of the same claim: a grid whose teardown could not + // establish the terminal was given back is a failed run, not a run with a + // warning in it. + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + // The server refuses to be killed the first time it is asked, so the last + // phase of the teardown cannot establish it is gone. + const tmux = createFakeTmux({ + script, + clientCommand, + spawnPanes: true, + failOnce: { command: "kill-server", message: "refused" }, + }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + [ + "", + '', + "", + "", + "AFTER_THE_GRID", + "", + ].join("\n"), + ); + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundTerminalGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + + yield* spawn(function* () { + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + while (tmux.clients.length === 0) { + yield* sleep(15); + } + yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + expect(outcome?.ok).toBe(false); + const refusal = outcome?.ok === false ? String(outcome.error) : ""; + expect(refusal).toContain("terminal server"); + // Nothing private in it, and nothing after the grid ran. + expect(refusal).not.toContain(room); + expect(output).not.toContain("AFTER_THE_GRID"); + }); + + it("TH1: without a terminal, a grid refuses before anything exists", function* () { + const before = yield* until(readdir(tmpdir())); + let refusal = ""; + try { + yield* scoped(function* () { + yield* installDenoTerminalProcesses(); + yield* useProbedProvider({ isTerminal: () => false }); + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + + expect(refusal).toContain("cannot open a terminal grid"); + expect(refusal).toContain("no terminal"); + // Before a directory, a socket, a token, a worker, a server or a pane: the + // host left nothing behind for having tried. + const after = yield* until(readdir(tmpdir())); + expect(after.filter((name) => name.startsWith("xmd-grid-")).length).toBe( + before.filter((name) => name.startsWith("xmd-grid-")).length, + ); + }); + + it("TH2: without a usable tmux, a grid refuses the same way", function* () { + let refusal = ""; + try { + yield* scoped(function* () { + yield* installDenoTerminalProcesses(); + yield* useProbedProvider({ + isTerminal: () => true, + // A tmux far too old for an explicit layout string. + version: "tmux 1.8", + }); + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("cannot open a terminal grid"); + expect(refusal).toContain("older than tmux"); + }); + + it("TH4: the installed SIGHUP listener cancels the run and tears the grid down", function* () { + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + [ + "", + '', + "", + "", + "AFTER_THE_GRID", + "", + ].join("\n"), + ); + // The run's foreground lease, which a grid takes before any provider. + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + const sighupBefore = foregroundSignalListeners("SIGHUP"); + let directory = ""; + let installed = 0; + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundTerminalGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + directory = at; + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + // The listener is the installer's, and this row uses that one. + installed = foregroundSignalListeners("SIGHUP"); + + yield* spawn(function* () { + // Driven by the pane child's own start: the worker spawned, its channel + // authenticated, and the shell it launched said so. + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + process.kill(process.pid, "SIGHUP"); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + // The installer put its listener on, and took it off with the run. + expect(installed).toBe(sighupBefore + 1); + expect(foregroundSignalListeners("SIGHUP")).toBe(sighupBefore); + + // Cancellation, not a reader close: the run failed and nothing after the + // grid ran in that attempt. + expect(outcome?.ok).toBe(false); + expect(output).not.toContain("AFTER_THE_GRID"); + + // Every teardown phase completed before the result was observed. The pane's + // child is gone, the worker is gone, the server is gone, and the private + // directory — which is removed last, after its sockets have closed — is + // gone with them. + const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim()); + expect(shellPid).toBeGreaterThan(0); + yield* installDenoTerminalProcesses(); + expect(yield* processReachable(shellPid)).toBe(false); + // Awaited on each process's own exit event, not sampled: a worker that had + // not quite gone yet would make a sampled check pass or fail by timing. + for (const child of tmux.started) { + yield* exited(child); + } + expect(tmux.alive()).toBe(false); + expect(directory).not.toBe(""); + expect(yield* exists(directory)).toBe(false); + }); + + it("TH5: an ordinary run shows the grid, and the reader's detach ends it", function* () { + // The same host, the same document and the same live grid as TH4. What + // differs is the ending: the reader leaves rather than the terminal going + // away, so the grid settles and the document carries on — which is the + // branch `useHangupCancellation()` has to hand the result back through. + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + [ + "", + '', + "", + "", + "AFTER_THE_GRID", + "", + ].join("\n"), + ); + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + let directory = ""; + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundTerminalGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + directory = at; + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + + yield* spawn(function* () { + // Driven by the grid's own progress: the pane child started, and the + // server has a reader's client to report the detach of. No SIGHUP. + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + while (tmux.clients.length === 0) { + yield* sleep(15); + } + yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + // The exact result, handed back through the hangup wrapper rather than + // swallowed by it: a handler that answered with nothing would be refused + // for having returned before the document produced a result. + expect(outcome).toEqual(Ok("\n\nAFTER_THE_GRID\n")); + // The reader closed the grid; the document went on. + expect(output).toContain("AFTER_THE_GRID"); + + // And it went on over a grid that had actually been taken down: the pane's + // child, the workers, the server and the private directory are all gone. + const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim()); + expect(shellPid).toBeGreaterThan(0); + yield* installDenoTerminalProcesses(); + expect(yield* processReachable(shellPid)).toBe(false); + for (const child of tmux.started) { + yield* exited(child); + } + expect(tmux.alive()).toBe(false); + expect(directory).not.toBe(""); + expect(yield* exists(directory)).toBe(false); + }); + + it("TH6: the Deno and compiled entrypoints present grids; Node and Bun do not", function* () { + for (const name of ["deno.ts", "compiled.ts"]) { + expect((yield* entrypointSource(name)).includes("foregroundTerminalGrid()")).toBe(true); + } + for (const name of ["node.ts", "bun.ts"]) { + // Not a different grid: no grid at all, and therefore the default the + // shared entry declares — which is the installation that validates a grid + // and presents none. + expect((yield* entrypointSource(name)).includes("foregroundTerminalGrid")).toBe(false); + } + expect(yield* entrypointSource("cli.ts")).toContain( + "installTerminalGrid: TerminalGridInstaller = unsupportedTerminalGrid", + ); + }); + + it("TH7: a pane's child is told the terminal's colour depth without a shell startup", function* () { + // The reported defect: an agent launched into a pane was colourless while + // the same program run by hand in the grid's Shell pane had colour. By + // hand it had colour because an interactive shell sources the reader's + // startup files, and theirs export `COLORTERM`. A pane's direct child + // sources nothing, so what it knows about the terminal is only what the + // host hands it — and `COLORTERM` was not in that list. + // + // Deliberately without an `env` override, so `paneEnvironment()` is what + // builds the environment. The shell here is a plain script: it records what + // it was given before doing anything, so nothing a startup file might add + // can be mistaken for what the pane provided. + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + + const hadColor = process.env.COLORTERM; + const hadShell = process.env.SHELL; + process.env.COLORTERM = "truecolor"; + process.env.SHELL = shell; + yield* ensure(() => { + if (hadColor === undefined) { + delete process.env.COLORTERM; + } else { + process.env.COLORTERM = hadColor; + } + if (hadShell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = hadShell; + } + }); + + yield* writeTextFile( + path.join(room, "doc.md"), + ["", '', "", ""].join( + "\n", + ), + ); + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + yield* scoped(function* () { + yield* foregroundTerminalGrid({ + isTerminal: () => true, + createTmux: () => tmux, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + + yield* spawn(function* () { + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + while (tmux.clients.length === 0) { + yield* sleep(15); + } + yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + next = yield* subscription.next(); + } + yield* execution; + }); + + const given = yield* readTextFile(`${room}/shell-env`); + // What the terminal is, and how much of it the child may use. + expect(given).toContain("TERM="); + expect(given).toContain("COLORTERM=truecolor"); + }); + + it("TH3: a host that installs no provider still validates the grid", function* () { + // Node and Bun: the same language and the same validation, and core's own + // refusal rather than a provider that half-works. + yield* unsupportedTerminalGrid(); + let refusal = ""; + try { + yield* TerminalGrids.operations.open({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("no terminal provider is installed"); + }); +}); diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 568ae1e19..4e80efd47 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -152,36 +152,12 @@ export { DocumentOutput } from "./src/api.ts"; export type { DocumentOutputApi } from "./src/api.ts"; export { useNormalizedOutput } from "./src/output/normalize.ts"; export { useTerminalOutput } from "./src/output/terminal.ts"; -export { - createTerminalAuthority, - createTerminalGridClaims, - TerminalAuthorityError, - terminalInstallation, - useTerminalInstallation, -} from "./src/terminal/authority.ts"; -export type { - PaneReadiness, - TerminalGridAuthority, - TerminalGridClaims, - TerminalPaneClaim, -} from "./src/terminal/authority.ts"; -export { - installTerminalProvider, - registerTerminalProvider, - TERMINAL_PROVIDERS_API, - TerminalProviderInstallError, - TerminalProviders, -} from "./src/terminal/provider-api.ts"; -export type { - TerminalProviderFactory, - TerminalProviderInstallRequest, - TerminalProviderOptions, -} from "./src/terminal/provider-api.ts"; +// The terminal domain is `@executablemd/terminal`'s, and a caller names it +// directly (DEC-016). What core exports here is only what core owns: the +// profile that composes a grid into an `Execution`, adapting the terminal +// lifecycle to this engine's journal descriptions and installation. export { installTerminalGridProfile } from "./src/terminal/profile.ts"; export type { TerminalGridProfileOptions } from "./src/terminal/profile.ts"; -export { paneTerminal } from "./src/terminal/pane.ts"; -export type { PaneTerminal } from "./src/terminal/pane.ts"; -export type { PaneStatus, RetainedGrid, RetainedPaneOutcome } from "./src/terminal/grid.ts"; export { execute, Execution } from "./src/execute.ts"; export type { diff --git a/packages/core/package.json b/packages/core/package.json index 8dadac530..1fc4c93cf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,6 +20,7 @@ "@effectionx/timebox": "0.4.3", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@secretlint/core": "13.0.4", "@secretlint/profiler": "13.0.4", "@secretlint/secretlint-rule-preset-recommend": "13.0.4", diff --git a/packages/core/src/agent/function-components.ts b/packages/core/src/agent/function-components.ts index 043d02b93..c656e64f1 100644 --- a/packages/core/src/agent/function-components.ts +++ b/packages/core/src/agent/function-components.ts @@ -23,7 +23,8 @@ import { sessionPlacement } from "./session-request.ts"; import type { ComponentInvocation, FunctionComponent } from "../types.ts"; import type { IdentityClaimant } from "../invocation-identity.ts"; -import { cwd, flushOutput, parseDuration, reserveTerminal } from "@executablemd/runtime"; +import { cwd, parseDuration } from "@executablemd/runtime"; +import { flushOutput, reserveTerminal } from "@executablemd/terminal"; import type { Json, PropsSchema } from "../types.ts"; import type { Expansion } from "../expansion.ts"; import { Agent } from "./agent-api.ts"; diff --git a/packages/core/src/agent/launch-owner.ts b/packages/core/src/agent/launch-owner.ts index 7c7e85285..3be70ec66 100644 --- a/packages/core/src/agent/launch-owner.ts +++ b/packages/core/src/agent/launch-owner.ts @@ -16,7 +16,8 @@ import { createApi } from "@effectionx/context-api"; import { scoped } from "effection"; import type { Operation, Stream } from "effection"; -import { cwd, flushOutput, reserveTerminal } from "@executablemd/runtime"; +import { cwd } from "@executablemd/runtime"; +import { flushOutput, reserveTerminal } from "@executablemd/terminal"; import { Agent, AGENT_API } from "./agent-api.ts"; import type { AgentApi, diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index c84a78681..b123391c5 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -66,13 +66,15 @@ import { terminalTitleMissingMessage, } from "./structural-rules.ts"; import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; -import { terminalGridLayout } from "./terminal-grid.ts"; -import type { PlacedPane } from "./terminal-grid.ts"; -import { durableGrid, openTerminalGrid, toRequest } from "./terminal/grid.ts"; -import type { PaneWork } from "./terminal/grid.ts"; +import { + durableGrid, + openTerminalGrid, + terminalGridLayout, + toRequest, +} from "@executablemd/terminal/lifecycle"; +import type { PaneWork, PlacedPane } from "@executablemd/terminal/lifecycle"; +import { usePaneNativeLauncher, usePaneTerminal } from "@executablemd/terminal"; import { recordGridLayout } from "./terminal/journal.ts"; -import { usePaneTerminal } from "./terminal/pane.ts"; -import { usePaneNativeLauncher } from "./terminal/pane-launcher.ts"; import { asBindingViolation, asExpressionViolation, diff --git a/packages/core/src/terminal/journal.ts b/packages/core/src/terminal/journal.ts index 3ee4fd8d4..d7d803e6c 100644 --- a/packages/core/src/terminal/journal.ts +++ b/packages/core/src/terminal/journal.ts @@ -25,12 +25,12 @@ import { StaleInputError, } from "@executablemd/durable-streams"; import type { EffectDescription, Json, Workflow } from "@executablemd/durable-streams"; -import type { TerminalGridRequest } from "@executablemd/runtime"; +import type { TerminalGridRequest } from "@executablemd/terminal"; import { sourceDescription } from "../source-position.ts"; import type { SourcePosition } from "../types.ts"; -import { retainedLayout } from "./grid.ts"; -import type { RetainedGrid } from "./grid.ts"; +import { retainedLayout } from "@executablemd/terminal/lifecycle"; +import type { RetainedGrid } from "@executablemd/terminal/lifecycle"; /** A grid's identity within one execution: where it was written. */ export interface GridIdentity { diff --git a/packages/core/src/terminal/profile.ts b/packages/core/src/terminal/profile.ts index 05919b653..7b687d981 100644 --- a/packages/core/src/terminal/profile.ts +++ b/packages/core/src/terminal/profile.ts @@ -15,8 +15,7 @@ import { scoped } from "effection"; import type { Operation } from "effection"; import { Execution } from "../execute.ts"; -import { useTerminalInstallation } from "./authority.ts"; -import { installTerminalProvider } from "./provider-api.ts"; +import { installTerminalProvider, useTerminalInstallation } from "@executablemd/terminal/lifecycle"; export interface TerminalGridProfileOptions { /** diff --git a/packages/core/tests/agent-session-launch.test.ts b/packages/core/tests/agent-session-launch.test.ts index 9905a2b44..8ce019cd5 100644 --- a/packages/core/tests/agent-session-launch.test.ts +++ b/packages/core/tests/agent-session-launch.test.ts @@ -33,22 +33,23 @@ import { parsePrepared } from "../src/agent/launch-journal.ts"; import type { AgentLaunchRequest } from "../src/agent/launch-request.ts"; import { installAgentComponents } from "../src/agent/components.ts"; import type { AgentProviderFactory } from "../src/agent/provider-api.ts"; +import { API, useHostFiles } from "@executablemd/runtime"; import { - API, - installControlledLauncher, NATIVE_LAUNCHER_UNAVAILABLE, nativeLaunch, - prepareControlledComposite, reserveTerminal, TerminalGrids, +} from "@executablemd/terminal"; +import { + installControlledLauncher, + prepareControlledComposite, terminalProviderLog, - useHostFiles, -} from "@executablemd/runtime"; -import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/runtime"; -import { createTerminalGridClaims } from "../src/terminal/authority.ts"; -import { usePaneNativeLauncher } from "../src/terminal/pane-launcher.ts"; +} from "@executablemd/terminal/test"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/terminal"; +import { createTerminalGridClaims } from "@executablemd/terminal/lifecycle"; +import { usePaneNativeLauncher } from "@executablemd/terminal"; import { installTerminalGridProfile } from "../src/terminal/profile.ts"; -import { registerTerminalProvider } from "../src/terminal/provider-api.ts"; +import { registerTerminalProvider } from "@executablemd/terminal"; import type { Json } from "../src/types.ts"; const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; diff --git a/packages/core/tests/terminal-grid-structure.test.ts b/packages/core/tests/terminal-grid-structure.test.ts index 626cbf34b..b2fc9b3b2 100644 --- a/packages/core/tests/terminal-grid-structure.test.ts +++ b/packages/core/tests/terminal-grid-structure.test.ts @@ -23,7 +23,7 @@ import { Component } from "../src/component-api.ts"; import { expandSegments } from "../src/expand.ts"; import { renderSegments } from "../src/render.ts"; import { scanSegments } from "../src/scanner.ts"; -import { terminalGridLayout } from "../src/terminal-grid.ts"; +import { terminalGridLayout } from "@executablemd/terminal/lifecycle"; import type { Json, Segment } from "../src/types.ts"; interface GridRun { diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 8c0ed596f..424138056 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -41,20 +41,18 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; +import { reserveTerminal, TerminalGrids } from "@executablemd/terminal"; import { installControlledLauncher, prepareControlledComposite, - reserveTerminal, - TerminalGrids, terminalProviderLog, -} from "@executablemd/runtime"; +} from "@executablemd/terminal/test"; +import type { TerminalComposite, TerminalGridRequest } from "@executablemd/terminal"; import type { ControlledCompositeOptions, - TerminalComposite, - TerminalGridRequest, TerminalProviderLog, TerminalProviderResources, -} from "@executablemd/runtime"; +} from "@executablemd/terminal/test"; import { Component } from "../src/component-api.ts"; import { execute } from "../src/execute.ts"; @@ -63,16 +61,16 @@ import { createTerminalGridClaims, TerminalAuthorityError, useTerminalInstallation, -} from "../src/terminal/authority.ts"; -import type { TerminalGridAuthority } from "../src/terminal/authority.ts"; +} from "@executablemd/terminal/lifecycle"; +import type { TerminalGridAuthority } from "@executablemd/terminal/lifecycle"; +import { installTerminalProvider } from "@executablemd/terminal/lifecycle"; import { - installTerminalProvider, registerTerminalProvider, TerminalProviderInstallError, TerminalProviders, -} from "../src/terminal/provider-api.ts"; +} from "@executablemd/terminal"; import { installTerminalGridProfile } from "../src/terminal/profile.ts"; -import { paneTerminal } from "../src/terminal/pane.ts"; +import { paneTerminal } from "@executablemd/terminal"; import type { Json } from "../src/types.ts"; /** One document run against a controlled grid host. */ diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index 06182eada..fe4e38f68 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -17,8 +17,6 @@ * this xmd, and eval-block compilation * (`cwd`, `env`, `platform`, `command`, `compile`) * - `API.Service` — scoped attached service startup (`startService`) - * - `NativeLauncher` — handing one native agent UI the foreground terminal - * (`reserveTerminal`, `flushOutput`, `nativeLaunch`) * - `Config` — shared execution config (`timeout`, `timeoutExec`, `timeoutFetch`, * `verbose`) * @@ -129,66 +127,6 @@ export type { FileWriteTarget, GlobInput, } from "./files.ts"; -export { - flushOutput, - installControlledLauncher, - installForegroundLauncher, - NATIVE_LAUNCHER_UNAVAILABLE, - NativeLauncher, - NativeLauncherUnavailableError, - nativeLaunch, - NO_TERMINAL, - reserveTerminal, -} from "./launcher.ts"; -export type { - ControlledLauncherOptions, - NativeLauncherHandler, - NativeLaunchOutcome, - NativeLaunchRequest, -} from "./launcher.ts"; -export { - prepareControlledComposite, - TERMINAL_GRIDS_API, - TERMINAL_PROVIDER_UNAVAILABLE, - TerminalGrids, - terminalProviderLog, - TerminalProviderUnavailableError, -} from "./terminal.ts"; -export type { - ControlledCompositeOptions, - TerminalComposite, - TerminalGridApi, - TerminalGridRequest, - TerminalPaneRequest, - TerminalPaneState, - TerminalProviderLog, - TerminalProviderResources, - TerminalShellOutcome, -} from "./terminal.ts"; -export { - descendantsOf, - deliverSignal, - establishQuiescence, - groupMembers, - paneOccupants, - processReachable, - processTable, - TERMINAL_PROCESSES_API, - TERMINAL_PROCESSES_UNAVAILABLE, - TerminalProcesses, - TerminalProcessesUnavailableError, - terminalHolders, -} from "./terminal-processes.ts"; -export type { - PaneOccupants, - PaneQuiescence, - ProcessFacts, - SignalDelivery, - TerminalProcessHandler, - TerminalSignal, -} from "./terminal-processes.ts"; -export { installDenoTerminalProcesses, posixProcessProbes } from "./deno-terminal-processes.ts"; -export type { ProcessProbes } from "./deno-terminal-processes.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; export type { HostFilesEvent, HostFilesObserver, HostFilesOptions } from "./host-files.ts"; export { diff --git a/packages/terminal-tmux/deno.json b/packages/terminal-tmux/deno.json new file mode 100644 index 000000000..828e77aa5 --- /dev/null +++ b/packages/terminal-tmux/deno.json @@ -0,0 +1,8 @@ +{ + "name": "@executablemd/terminal-tmux", + "version": "0.11.0", + "exports": { + ".": "./mod.ts", + "./test": "./testing.ts" + } +} diff --git a/packages/terminal-tmux/mod.ts b/packages/terminal-tmux/mod.ts new file mode 100644 index 000000000..c98521662 --- /dev/null +++ b/packages/terminal-tmux/mod.ts @@ -0,0 +1,28 @@ +/** + * The tmux presentation provider for terminal grids + * (architecture.md §Package ownership). + * + * The first implementation of the provider-neutral domain in + * `@executablemd/terminal`, and the only place tmux appears. A host that can + * divide its terminal installs this; one that cannot installs nothing and the + * document meets core's own refusal rather than a provider that half-works. + * + * The surface is deliberately narrow: a name, what a host must supply, the + * factory and its installer, the hidden verb one pane's worker is re-invoked + * under, and the refusals a reader can actually meet. Every tmux command, the + * private protocol, the channel handles, the layout mechanics and the teardown + * controls stay inside — a second provider API is not what this is. The seams + * this adapter's own tests drive live in `./test`. + */ + +export { installTmuxGridProvider, TMUX_PROVIDER, tmuxGridProvider } from "./src/provider.ts"; +export type { TmuxProviderDependencies } from "./src/provider.ts"; + +export { + PANE_WORKER_COMMAND, + PaneNotQuiescent, + paneWorkerInvocation, + runPaneWorkerProcess, +} from "./src/pane-worker.ts"; + +export { TerminalTeardownFailed, TMUX_UNAVAILABLE, TmuxUnavailableError } from "./src/tmux.ts"; diff --git a/packages/terminal-tmux/package.json b/packages/terminal-tmux/package.json new file mode 100644 index 000000000..f0464fcaa --- /dev/null +++ b/packages/terminal-tmux/package.json @@ -0,0 +1,17 @@ +{ + "name": "@executablemd/terminal-tmux", + "version": "0.11.0", + "description": "The tmux presentation provider for executable.md terminal grids.", + "type": "module", + "exports": { + ".": "./mod.ts", + "./test": "./testing.ts" + }, + "dependencies": { + "@effectionx/fs": "0.3.0", + "@effectionx/process": "0.8.1", + "@executablemd/terminal": "workspace:*", + "effection": "4.1.0", + "zod": "^4.3.6" + } +} diff --git a/packages/cli/src/terminal/attach-client.ts b/packages/terminal-tmux/src/attach-client.ts similarity index 98% rename from packages/cli/src/terminal/attach-client.ts rename to packages/terminal-tmux/src/attach-client.ts index f9a6d5b10..33eeda07d 100644 --- a/packages/cli/src/terminal/attach-client.ts +++ b/packages/terminal-tmux/src/attach-client.ts @@ -20,7 +20,7 @@ import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import { ensure, race, resource, sleep, withResolvers } from "effection"; import type { Operation } from "effection"; -import { deliverSignal, processReachable } from "@executablemd/runtime"; +import { deliverSignal, processReachable } from "@executablemd/terminal/processes"; import { TerminalTeardownFailed } from "./tmux.ts"; export interface AttachClient { diff --git a/packages/cli/src/terminal/layout.ts b/packages/terminal-tmux/src/layout.ts similarity index 100% rename from packages/cli/src/terminal/layout.ts rename to packages/terminal-tmux/src/layout.ts diff --git a/packages/cli/src/terminal/pane-channel.ts b/packages/terminal-tmux/src/pane-channel.ts similarity index 99% rename from packages/cli/src/terminal/pane-channel.ts rename to packages/terminal-tmux/src/pane-channel.ts index 08bd86132..907781614 100644 --- a/packages/cli/src/terminal/pane-channel.ts +++ b/packages/terminal-tmux/src/pane-channel.ts @@ -38,9 +38,9 @@ import type { Operation } from "effection"; import { ensureDir, rm, writeTextFile } from "@effectionx/fs"; import { chmod } from "node:fs/promises"; import { - FromWorkerSchema, paneSocketPath, paneTokenPath, + parseFromWorker, readFrames, writeFrame, } from "./pane-protocol.ts"; @@ -263,7 +263,7 @@ export function usePaneChannels( function* admit(ordinal: number, socket: Socket): Operation { const slot = slots.get(ordinal); const token = tokens.get(ordinal); - const frames = yield* readFrames(socket, (value) => FromWorkerSchema.parse(value)); + const frames = yield* readFrames(socket, (value) => parseFromWorker(value)); const first = yield* race([frames.next(), silence()]); if (slot === undefined || token === undefined || first.done || first.value.type !== "hello") { refusals.push(`pane ${ordinal}: a connection that did not say hello`); diff --git a/packages/cli/src/terminal/pane-child.ts b/packages/terminal-tmux/src/pane-child.ts similarity index 96% rename from packages/cli/src/terminal/pane-child.ts rename to packages/terminal-tmux/src/pane-child.ts index 107b969d2..d96b3db70 100644 --- a/packages/cli/src/terminal/pane-child.ts +++ b/packages/terminal-tmux/src/pane-child.ts @@ -31,13 +31,21 @@ import { processReachable, processTable, terminalHolders, -} from "@executablemd/runtime"; +} from "@executablemd/terminal/processes"; import type { Settlement } from "./pane-protocol.ts"; export interface PaneChildRequest { readonly argv: readonly string[]; readonly cwd: string; - readonly env: Record; + /** + * The child's environment, or absent to inherit this worker's. + * + * Absent is meaningful: tmux started this worker with the pane's + * environment, so inheriting it is how a launch that named none gets the + * terminal it is drawing on — `TERM`, and `COLORTERM` where the host has one. + * A supplied environment is used exactly, with nothing ambient added. + */ + readonly env?: Record; } export interface PaneChildOutcome { diff --git a/packages/cli/src/terminal/pane-protocol.ts b/packages/terminal-tmux/src/pane-protocol.ts similarity index 57% rename from packages/cli/src/terminal/pane-protocol.ts rename to packages/terminal-tmux/src/pane-protocol.ts index 61b6820bc..61ece9ffc 100644 --- a/packages/cli/src/terminal/pane-protocol.ts +++ b/packages/terminal-tmux/src/pane-protocol.ts @@ -25,8 +25,105 @@ import { createQueue, ensure, resource, withResolvers } from "effection"; import type { Operation, Queue } from "effection"; import { z } from "zod"; +/** + * The wire format, written out. + * + * Declared rather than inferred from the schemas below, and the schemas are + * then annotated with these types so the compiler holds the two together — a + * schema that stopped producing its declared frame stops compiling, so there is + * no drift to keep an eye on. + * + * Written out because this package is published: an inferred zod type has no + * explicit form to publish, and the frames are the one part of this adapter + * whose shape a reader of the package genuinely needs. The schemas themselves + * stay private — how a frame is validated is nobody else's business, and + * `parseFromWorker`/`parseToWorker` are the seam. + */ + +/** What one worker says about the pane it woke up in. */ +export interface Hello { + type: "hello"; + ordinal: number; + token: string; + pid: number; + pgid: number; + /** `ttys003`, or `??` when the worker has no controlling terminal. */ + tty: string; + /** Whether stdin, stdout and stderr are terminals. All three must be. */ + isatty: [boolean, boolean, boolean]; +} + +/** One process the settlement reached, and what reaching it established. */ +export interface Swept { + pid: number; + gone: boolean; +} + +/** + * What a settlement established, in the order it established it. + * + * `quiet` is the only field a caller may act on, and it is true only when the + * child, everything the snapshot said was below or beside it, and every holder + * of the pane's terminal are gone. The rest is what a diagnostic says when it + * is not. + */ +export interface Settlement { + method: "exited" | "interrupted" | "killed"; + quiet: boolean; + child?: number; + /** Snapshot members reached during the escalation. */ + swept: Swept[]; + /** Anything still holding the pane's terminal after the sweep. */ + holders: Swept[]; +} + +/** Everything a worker may say. */ +export type FromWorker = + | Hello + | { type: "displayed"; seq: number } + /** The runtime's spawn event, and nothing earlier. */ + | { type: "started"; id: string; pid: number } + | { type: "start-failed"; id: string; reason: string } + /** A launch asked for while one is live. */ + | { type: "busy"; id: string } + | { + type: "exited"; + id: string; + exitCode?: number; + signal?: string; + /** The settlement that preceded this; the pane is free once it arrives. */ + settlement: Settlement; + } + | { type: "quiet"; id?: string; settlement: Settlement } + | { type: "bye"; holders: Swept[] }; + +/** Everything the parent may say. */ +export type ToWorker = + | { type: "welcome" } + | { type: "display"; seq: number; text: string } + /** + * Start a program on this pane's terminal. + * + * `env` omitted and `env` empty are different instructions, which is why it + * is optional rather than defaulted. Omitted means "the environment you + * already have" — the pane's, which tmux gave this worker — and is what a + * caller that named no environment meant. An empty map means "start this with + * nothing", which is a thing a caller may ask for and which no default should + * silently produce. Collapsing the first into the second is how a launched + * program came to run with no `TERM`, no `PATH` and no `HOME` at all. + */ + | { + type: "launch"; + id: string; + argv: string[]; + cwd: string; + env?: Record; + } + | { type: "cancel"; id: string } + | { type: "shutdown" }; + /** What one worker says about the pane it woke up in. */ -export const HelloSchema = z.object({ +const HelloSchema = z.object({ type: z.literal("hello"), ordinal: z.number().int().nonnegative(), token: z.string(), @@ -52,7 +149,7 @@ const SweptSchema = z.object({ * of the pane's terminal are gone. The rest is what a diagnostic says when it * is not. */ -export const SettlementSchema = z.object({ +const SettlementSchema = z.object({ method: z.enum(["exited", "interrupted", "killed"]), quiet: z.boolean(), child: z.number().int().optional(), @@ -62,7 +159,7 @@ export const SettlementSchema = z.object({ holders: z.array(SweptSchema), }); -export const FromWorkerSchema = z.discriminatedUnion("type", [ +const FromWorkerSchema = z.discriminatedUnion("type", [ HelloSchema, z.object({ type: z.literal("displayed"), seq: z.number().int() }), /** The runtime's spawn event, and nothing earlier. */ @@ -86,7 +183,7 @@ export const FromWorkerSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("bye"), holders: z.array(SweptSchema) }), ]); -export const ToWorkerSchema = z.discriminatedUnion("type", [ +const ToWorkerSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("welcome") }), z.object({ type: z.literal("display"), seq: z.number().int(), text: z.string() }), z.object({ @@ -94,16 +191,40 @@ export const ToWorkerSchema = z.discriminatedUnion("type", [ id: z.string(), argv: z.array(z.string()).min(1), cwd: z.string(), - env: z.record(z.string(), z.string()), + // Optional, not defaulted: an absent `env` and an empty one are different + // instructions. Still exact when present — a value that is not a string + // makes the frame malformed rather than being coerced. + env: z.record(z.string(), z.string()).optional(), }), z.object({ type: z.literal("cancel"), id: z.string() }), z.object({ type: z.literal("shutdown") }), ]); -export type Hello = z.infer; -export type FromWorker = z.infer; -export type ToWorker = z.infer; -export type Settlement = z.infer; +// The schemas are held to the declared frames rather than the frames being +// read off the schemas. A change to either that the other does not match is a +// type error here, at the one place both are in view. +const _hello: z.ZodType = HelloSchema; +const _settlement: z.ZodType = SettlementSchema; +const _fromWorker: z.ZodType = FromWorkerSchema; +const _toWorker: z.ZodType = ToWorkerSchema; + +/** + * Read one frame in each direction, or refuse it. + * + * The seam is the parse rather than the schema. A schema is how this module + * happens to decide what a frame is; what a caller — including this adapter's + * own tests — actually needs is "turn these bytes into a frame or throw", and + * a function saying exactly that keeps the shape of the wire format private. + * It also keeps it out of the published API, where an inferred zod type has no + * explicit form to publish. + */ +export function parseFromWorker(value: unknown): FromWorker { + return FromWorkerSchema.parse(value); +} + +export function parseToWorker(value: unknown): ToWorker { + return ToWorkerSchema.parse(value); +} /** * Where one pane's socket and token live. diff --git a/packages/cli/src/terminal/pane-worker.ts b/packages/terminal-tmux/src/pane-worker.ts similarity index 98% rename from packages/cli/src/terminal/pane-worker.ts rename to packages/terminal-tmux/src/pane-worker.ts index 81f175a06..b4d7d283e 100644 --- a/packages/cli/src/terminal/pane-worker.ts +++ b/packages/terminal-tmux/src/pane-worker.ts @@ -28,14 +28,15 @@ import process from "node:process"; import { readTextFile, rm } from "@effectionx/fs"; import { ensure, resource, run, spawn, withResolvers } from "effection"; import type { Operation } from "effection"; -import { installDenoTerminalProcesses, processTable } from "@executablemd/runtime"; +import { processTable } from "@executablemd/terminal/processes"; +import { installDenoTerminalProcesses } from "@executablemd/terminal/posix"; import { sweepHolders, usePaneChild } from "./pane-child.ts"; import type { PaneChild, PaneChildRequest } from "./pane-child.ts"; import { paneSocketPath, paneTokenPath, + parseToWorker, readFrames, - ToWorkerSchema, writeFrame, } from "./pane-protocol.ts"; import type { FromWorker, Settlement } from "./pane-protocol.ts"; @@ -240,7 +241,7 @@ export function* runPaneWorker( socket.off("error", onConnectError); } - const inbound = yield* readFrames(socket, (value) => ToWorkerSchema.parse(value)); + const inbound = yield* readFrames(socket, (value) => parseToWorker(value)); const say = (message: FromWorker) => writeFrame(socket, message); const table = yield* processTable(); diff --git a/packages/cli/src/terminal/provider.ts b/packages/terminal-tmux/src/provider.ts similarity index 96% rename from packages/cli/src/terminal/provider.ts rename to packages/terminal-tmux/src/provider.ts index 5c33258f0..3fc04e462 100644 --- a/packages/cli/src/terminal/provider.ts +++ b/packages/terminal-tmux/src/provider.ts @@ -23,17 +23,16 @@ import { ensure, resource, withResolvers } from "effection"; import process from "node:process"; import type { Operation } from "effection"; -import { TerminalGrids } from "@executablemd/runtime"; +import { registerTerminalProvider, TerminalGrids } from "@executablemd/terminal"; import type { NativeLaunchOutcome, NativeLaunchRequest, TerminalComposite, TerminalGridRequest, TerminalPaneState, + TerminalProviderFactory, TerminalShellOutcome, -} from "@executablemd/runtime"; -import { registerTerminalProvider } from "@executablemd/core"; -import type { TerminalProviderFactory } from "@executablemd/core"; +} from "@executablemd/terminal"; import { usePaneChannels } from "./pane-channel.ts"; import { requireQuiescent } from "./pane-worker.ts"; import type { PaneLink } from "./pane-channel.ts"; @@ -407,7 +406,13 @@ export function* runInPane( id, argv: [...request.command], cwd: request.cwd, - env: request.env ?? {}, + // Carried only when the caller named one. `?? {}` used to sit here, and it + // turned "inherit" into "empty": at the root an absent `env` means the + // 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. + ...(request.env === undefined ? {} : { env: request.env }), }); while (true) { const frame = yield* link.next(); diff --git a/packages/cli/src/terminal/tmux-grid.ts b/packages/terminal-tmux/src/tmux-grid.ts similarity index 93% rename from packages/cli/src/terminal/tmux-grid.ts rename to packages/terminal-tmux/src/tmux-grid.ts index e1e5cab2b..065dd2fba 100644 --- a/packages/cli/src/terminal/tmux-grid.ts +++ b/packages/terminal-tmux/src/tmux-grid.ts @@ -30,12 +30,12 @@ import { exec } from "@effectionx/process"; import { lines } from "@effectionx/stream-helpers"; import { createSignal, ensure, resource, sleep, spawn } from "effection"; import type { Operation } from "effection"; -import { processReachable } from "@executablemd/runtime"; +import { processReachable } from "@executablemd/terminal/processes"; import { layoutString, swapsInto } from "./layout.ts"; import type { LayoutCell } from "./layout.ts"; import { useAttachClient } from "./attach-client.ts"; import type { AttachClient } from "./attach-client.ts"; -import { TerminalTeardownFailed } from "./tmux.ts"; +import { quietly, TerminalTeardownFailed } from "./tmux.ts"; import type { Tmux } from "./tmux.ts"; /** What one prepared pane is, from the composite's side. */ @@ -277,15 +277,24 @@ export function useTmuxGrid(tmux: Tmux, request: TmuxGridRequest): Operation(body: () => Operation): Operation { + return scoped(function* (): Operation { + yield* Stdio.around({ + // Neither stream reaches the host. Raw tmux stderr is never forwarded — + // what a caller may see is the provider's own normalized refusal. + // deno-lint-ignore require-yield + *stdout() {}, + // deno-lint-ignore require-yield + *stderr() {}, + }); + return yield* body(); + }); +} + /** One private tmux server, addressed by its socket. */ export interface Tmux { readonly socket: string; @@ -93,14 +128,21 @@ export function tmuxAt(socket: string, env: Record): Tmux { socket, argv: (args) => ["tmux", ...base, ...args], *run(args) { - const result = yield* exec("tmux", { arguments: [...base, ...args], env }).join(); + const result = yield* quietly(() => + exec("tmux", { arguments: [...base, ...args], env }).join(), + ); if (result.code !== 0) { + // The step name and nothing else. tmux's own stderr is not forwarded + // and does not travel in the refusal: it names sockets, sessions and + // panes, which are this invocation's private topology. throw new TmuxCommandFailed(args[0] ?? ""); } return result.stdout.trim(); }, *tryRun(args) { - const result = yield* exec("tmux", { arguments: [...base, ...args], env }).join(); + const result = yield* quietly(() => + exec("tmux", { arguments: [...base, ...args], env }).join(), + ); return result.code === 0 ? result.stdout.trim() : undefined; }, }; @@ -127,7 +169,7 @@ export function* probeTmux(options: { } const result = options.askVersion === undefined - ? yield* exec("tmux", { arguments: ["-V"], env: options.env }).join() + ? yield* quietly(() => exec("tmux", { arguments: ["-V"], env: options.env }).join()) : yield* options.askVersion(); if (result.code !== 0) { return Err(new TmuxUnavailableError("tmux is not installed or would not run")); @@ -162,23 +204,3 @@ function readVersion(reported: string): { major: number; minor: number } | undef } return { major: Number(major), minor: Number(minor) }; } - -/** - * The environment every process in the topology receives. - * - * Named rather than inherited wholesale: a pane's child gets what a terminal - * program needs and nothing this process happens to be carrying. - */ -export function paneEnvironment( - source: Record, -): Record { - const env: Record = {}; - for (const name of ["PATH", "HOME", "SHELL", "LANG", "TMPDIR", "USER", "LOGNAME"]) { - const value = source[name]; - if (value !== undefined && value !== "") { - env[name] = value; - } - } - env.TERM = source.TERM ?? "xterm-256color"; - return env; -} diff --git a/packages/terminal-tmux/testing.ts b/packages/terminal-tmux/testing.ts new file mode 100644 index 000000000..7b166e813 --- /dev/null +++ b/packages/terminal-tmux/testing.ts @@ -0,0 +1,55 @@ +/** + * The low-level seams this adapter's own evidence drives + * (architecture.md §Package ownership). + * + * Not a second provider API. These are the pieces a row needs to hold one + * layer to its contract — a channel without a server, a worker without tmux, a + * layout string without a window — and production code imports none of them. + * + * The export is `./test`; the file is `testing.ts` because Deno's own test-file + * pattern matches a bare `test.ts`, which would make the test runner load this + * entrypoint as a test file in every shard. + */ + +export { useAttachClient } from "./src/attach-client.ts"; +export type { AttachClient } from "./src/attach-client.ts"; +export { layoutString, placementProblems, rowMajorCells, swapsInto } from "./src/layout.ts"; +export type { LayoutCell, PaneSwap } from "./src/layout.ts"; +export { usePaneChannels } from "./src/pane-channel.ts"; +export type { PaneChannels, PaneLink } from "./src/pane-channel.ts"; +export { sweepHolders, usePaneChild } from "./src/pane-child.ts"; +export type { + PaneChild, + PaneChildOutcome, + PaneChildRequest, + PaneStartFailure, +} from "./src/pane-child.ts"; +export { + paneSocketPath, + paneTokenPath, + parseFromWorker, + parseToWorker, + readFrames, + writeFrame, +} from "./src/pane-protocol.ts"; +export type { FromWorker, Hello, Settlement, ToWorker } from "./src/pane-protocol.ts"; +export { + foregroundSignalListeners, + requireQuiescent, + runPaneWorker, + useForegroundSignals, +} from "./src/pane-worker.ts"; +export type { PaneWorkerDependencies } from "./src/pane-worker.ts"; +export { createGridTeardown, runInPane } from "./src/provider.ts"; +export type { GridParts } from "./src/provider.ts"; +export { classify, useTmuxGrid } from "./src/tmux-grid.ts"; +export type { + ControlEvent, + ServerStopped, + TmuxGrid, + TmuxGridRequest, + TmuxPane, + VisibleClient, +} from "./src/tmux-grid.ts"; +export { probeTmux, tmuxAt, TmuxCommandFailed } from "./src/tmux.ts"; +export type { Tmux } from "./src/tmux.ts"; diff --git a/packages/terminal-tmux/tests/fixtures/client-command.ts b/packages/terminal-tmux/tests/fixtures/client-command.ts new file mode 100644 index 000000000..3fce4e3a0 --- /dev/null +++ b/packages/terminal-tmux/tests/fixtures/client-command.ts @@ -0,0 +1,21 @@ +/** + * How to run the stand-in tmux client, resolved from where the fixture lives. + * + * The fixture belongs to this package, so the path is derived from this + * module's own URL rather than written relative to a repository root. A suite + * in another package drives the same client without knowing where it sits, and + * moving the fixture again cannot leave behind a stale string that starts no + * process — a failure that reads as "nothing was signalled" rather than as a + * missing file, and one that a row expecting a client to stay put can pass + * without noticing. + */ + +import { fileURLToPath } from "node:url"; +import { cliCommand } from "@executablemd/test-support/launch"; + +export function clientCommand(mode: "control" | "attach", script: string): readonly string[] { + const fixture = fileURLToPath(new URL("./tmux-client.ts", import.meta.url)); + const invocation = cliCommand([]); + // The same runtime the CLI runs under, pointed at the fixture instead. + return [invocation.command, "run", "--allow-all", fixture, mode, script]; +} diff --git a/packages/cli/tests/fixtures/fake-tmux.ts b/packages/terminal-tmux/tests/fixtures/fake-tmux.ts similarity index 98% rename from packages/cli/tests/fixtures/fake-tmux.ts rename to packages/terminal-tmux/tests/fixtures/fake-tmux.ts index f8909a7fa..c939ec940 100644 --- a/packages/cli/tests/fixtures/fake-tmux.ts +++ b/packages/terminal-tmux/tests/fixtures/fake-tmux.ts @@ -23,8 +23,8 @@ import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import { until } from "effection"; import type { Operation } from "effection"; -import { TmuxCommandFailed } from "../../src/terminal/tmux.ts"; -import type { Tmux } from "../../src/terminal/tmux.ts"; +import { TmuxCommandFailed } from "../../src/tmux.ts"; +import type { Tmux } from "../../src/tmux.ts"; export interface FakePane { id: string; diff --git a/packages/cli/tests/fixtures/tmux-client.ts b/packages/terminal-tmux/tests/fixtures/tmux-client.ts similarity index 80% rename from packages/cli/tests/fixtures/tmux-client.ts rename to packages/terminal-tmux/tests/fixtures/tmux-client.ts index 558d3af2d..bd1c7be47 100644 --- a/packages/cli/tests/fixtures/tmux-client.ts +++ b/packages/terminal-tmux/tests/fixtures/tmux-client.ts @@ -45,6 +45,21 @@ function write(text: string): Operation { return written.operation; } +function complain(text: string): Operation { + const written = withResolvers(); + process.stderr.write(text, () => written.resolve()); + return written.operation; +} + +/** + * A script line that makes this client complain instead of report. + * + * The two streams mean different things here — stdout is the control protocol + * and stderr is the client saying something went wrong — so a suite needs to + * drive them separately to show that suppressing one leaves the other alone. + */ +const COMPLAIN = "!stderr "; + /** Follow the script until it says this client is finished. */ export function* followScript(mode: Mode, script: string): Operation { let seen = 0; @@ -52,6 +67,10 @@ export function* followScript(mode: Mode, script: string): Operation { const lines = yield* said(script); for (const line of lines.slice(seen)) { if (mode === "control") { + if (line.startsWith(COMPLAIN)) { + yield* complain(`${line.slice(COMPLAIN.length)}\n`); + continue; + } yield* write(`${line}\n`); if (line.startsWith("%exit")) { return; diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts similarity index 82% rename from packages/cli/tests/terminal-grid-tmux.test.ts rename to packages/terminal-tmux/tests/terminal-grid-tmux.test.ts index 63129df40..3b8341878 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts @@ -39,64 +39,48 @@ import process from "node:process"; import { cliCommand } from "@executablemd/test-support/launch"; import { ensureDir, exists, readTextFile, rm, stat, writeTextFile } from "@effectionx/fs"; import { realpath } from "node:fs/promises"; -import { installControlledLauncher, nativeLaunch, reserveTerminal } from "@executablemd/runtime"; -import type { TerminalComposite } from "@executablemd/runtime"; +import { nativeLaunch, reserveTerminal, TerminalGrids } from "@executablemd/terminal"; +import type { TerminalComposite } from "@executablemd/terminal"; +import { installControlledLauncher } from "@executablemd/terminal/test"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { - installDenoTerminalProcesses, - processReachable, - TerminalProcesses, -} from "@executablemd/runtime"; -import type { SignalDelivery } from "@executablemd/runtime"; -import { useTmuxGrid } from "../src/terminal/tmux-grid.ts"; -import type { ControlEvent, TmuxGrid } from "../src/terminal/tmux-grid.ts"; +import { processReachable, TerminalProcesses } from "@executablemd/terminal/processes"; +import type { SignalDelivery } from "@executablemd/terminal/processes"; +import { installDenoTerminalProcesses } from "@executablemd/terminal/posix"; +import { useTmuxGrid } from "../src/tmux-grid.ts"; +import { tmuxAt } from "../src/tmux.ts"; +import type { ControlEvent, TmuxGrid } from "../src/tmux-grid.ts"; import { createFakeTmux } from "./fixtures/fake-tmux.ts"; import type { FakeTmux } from "./fixtures/fake-tmux.ts"; -import { - layoutString, - placementProblems, - rowMajorCells, - swapsInto, -} from "../src/terminal/layout.ts"; -import type { LayoutCell } from "../src/terminal/layout.ts"; -import { usePaneChannels } from "../src/terminal/pane-channel.ts"; -import { createGridTeardown, runInPane, tmuxGridProvider } from "../src/terminal/provider.ts"; -import { - foregroundTerminalGrid, - underHangup, - unsupportedTerminalGrid, -} from "../src/terminal/host.ts"; -import { - execute, - installTerminalProvider, - registerTerminalProvider, - useTerminalInstallation, -} from "@executablemd/core"; -import type { Json } from "@executablemd/core"; +import { clientCommand } from "./fixtures/client-command.ts"; +import { layoutString, placementProblems, rowMajorCells, swapsInto } from "../src/layout.ts"; +import type { LayoutCell } from "../src/layout.ts"; +import { usePaneChannels } from "../src/pane-channel.ts"; +import { createGridTeardown, runInPane, tmuxGridProvider } from "../src/provider.ts"; +import { installTerminalProvider, useTerminalInstallation } from "@executablemd/terminal/lifecycle"; +import { registerTerminalProvider } from "@executablemd/terminal"; import type { Result } from "effection"; -import { processTable, TerminalGrids } from "@executablemd/runtime"; +import { processTable } from "@executablemd/terminal/processes"; import { chmod, readdir } from "node:fs/promises"; import { InMemoryStream } from "@executablemd/durable-streams"; -import type { PaneChannels, PaneLink } from "../src/terminal/pane-channel.ts"; +import type { PaneChannels, PaneLink } from "../src/pane-channel.ts"; import { - FromWorkerSchema, paneSocketPath, paneTokenPath, + parseToWorker, readFrames, - ToWorkerSchema, writeFrame, -} from "../src/terminal/pane-protocol.ts"; +} from "../src/pane-protocol.ts"; import { foregroundSignalListeners, PANE_WORKER_COMMAND, paneWorkerInvocation, runPaneWorker, useForegroundSignals, -} from "../src/terminal/pane-worker.ts"; -import { usePaneChild } from "../src/terminal/pane-child.ts"; -import type { PaneChild, PaneChildOutcome } from "../src/terminal/pane-child.ts"; -import type { FromWorker, Settlement, ToWorker } from "../src/terminal/pane-protocol.ts"; +} from "../src/pane-worker.ts"; +import { usePaneChild } from "../src/pane-child.ts"; +import type { PaneChild, PaneChildOutcome } from "../src/pane-child.ts"; +import type { FromWorker, Settlement, ToWorker } from "../src/pane-protocol.ts"; /** The cells a layout string describes, read back out of it. */ function readCells(layout: string): LayoutCell[] { @@ -132,14 +116,6 @@ function useScript(): Operation { }); } -/** The fixture that stands in for one tmux client. */ -function clientCommand(mode: "control" | "attach", script: string): readonly string[] { - const fixture = path.resolve("packages/cli/tests/fixtures/tmux-client.ts"); - const invocation = cliCommand([]); - // The same runtime the CLI runs under, pointed at the fixture instead. - return [invocation.command, "run", "--allow-all", fixture, mode, script]; -} - /** Every listener this process holds, across the names this code installs. */ function processListeners(): number { return (["SIGINT", "SIGQUIT", "SIGTSTP", "SIGHUP"] as NodeJS.Signals[]).reduce( @@ -430,6 +406,44 @@ function closedWithin(socket: net.Socket, limitMs: number): Operation { })(); } +/** + * Tier TP — what the wire format itself admits. + * + * Its own block rather than a row inside Tier TW: these are the schema's + * answers, needing no socket, worker or process, and a tier that spawns real + * children is both slower and a worse place to read them. + */ +describe("Tier TP — the pane protocol's launch frame", () => { + it("TP5: a launch may omit an environment, name one exactly, or be refused", function* () { + // Three distinct answers, because a launch's `env` carries three distinct + // meanings. Omitted is "the environment you already have"; a map is that + // exact map, empty included; anything else is not the protocol. + const base = { type: "launch", id: "x", argv: ["/bin/true"], cwd: "/tmp" }; + + const omitted = parseToWorker(base); + expect(omitted.type === "launch" && omitted.env).toBe(undefined); + + const exact = parseToWorker({ ...base, env: { TERM: "xterm-256color" } }); + expect(exact.type === "launch" && exact.env).toEqual({ TERM: "xterm-256color" }); + + // Empty is a real instruction — start this with nothing — and survives as + // itself rather than being read as "omitted". + const empty = parseToWorker({ ...base, env: {} }); + expect(empty.type === "launch" && empty.env).toEqual({}); + + // Malformed rather than coerced: a number is not an environment value, and + // this channel is how one process is asked to start a program on a + // terminal. + let refused = ""; + try { + parseToWorker({ ...base, env: { TERM: 256 } }); + } catch (error) { + refused = error instanceof Error ? error.name : String(error); + } + expect(refused).not.toBe(""); + }); +}); + describe("Tier TW — the pane worker and its private channel", () => { it("TW1: the private directory is 0700 and its tokens 0600", function* () { const channels: PaneChannels = yield* usePaneChannels(2); @@ -872,6 +886,8 @@ const CLIENT_MARKER = "clientmarker7f3a"; const TITLE_MARKER = "titlemarker7f3a"; const ENV_MARKER = "envmarker7f3a"; const STDERR_MARKER = "stderrmarker7f3a"; +const TMUX_STDOUT_MARKER = "tmuxstdoutmarker7f3a"; +const TMUX_STDERR_MARKER = "tmuxstderrmarker7f3a"; describe("Tier TG — the tmux composite", () => { /** A host whose processes are all gone, so teardown proves itself. */ @@ -900,6 +916,68 @@ describe("Tier TG — the tmux composite", () => { } /** A composite over a fake server, with the pane workers stubbed out. */ + /** + * A `tmux` on `PATH` that answers on both streams. + * + * `list-panes` succeeds and writes to stdout; anything else fails and writes + * to stderr, which is the shape `run()` and `tryRun()` branch on. Being a + * program rather than an injected seam is the point: the forwarding under + * test belongs to the process boundary, so the row needs a real child. + */ + function useFakeTmuxProgram(): Operation { + return resource(function* (provide) { + const at = path.join(tmpdir(), `xmd-fake-tmux-${randomUUID()}`); + yield* ensureDir(at); + yield* ensure(function* () { + yield* rm(at, { recursive: true, force: true }); + }); + yield* writeTextFile( + path.join(at, "tmux"), + [ + "#!/bin/sh", + 'case "$*" in', + ` *list-panes*) echo "${TMUX_STDOUT_MARKER}"; exit 0;;`, + ` *) echo "${TMUX_STDERR_MARKER}" >&2; exit 1;;`, + "esac", + "", + ].join("\n"), + ); + yield* until(chmod(path.join(at, "tmux"), 0o755)); + yield* provide(at); + }); + } + + /** + * Watch what this process actually writes to its own terminal. + * + * The boundary being defended is the host's streams, so that is what is + * observed rather than a provider's intentions: the real writes are replaced + * for the length of the row, recorded, still written so a failing row stays + * readable, and restored on the way out. + */ + function useHostStreams(): Operation<{ written: string[]; complained: string[] }> { + return resource<{ written: string[]; complained: string[] }>(function* (provide) { + const written: string[] = []; + const complained: string[] = []; + const realOut = process.stdout.write.bind(process.stdout); + const realErr = process.stderr.write.bind(process.stderr); + const record = + (into: string[], real: typeof realOut, stream: NodeJS.WriteStream) => + (chunk: string | Uint8Array, ...rest: unknown[]): boolean => { + into.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return Reflect.apply(real, stream, [chunk, ...rest]); + }; + process.stdout.write = record(written, realOut, process.stdout); + process.stderr.write = record(complained, realErr, process.stderr); + try { + yield* provide({ written, complained }); + } finally { + process.stdout.write = realOut; + process.stderr.write = realErr; + } + }); + } + function useComposite(options: { panes: number; columns: number; @@ -1035,6 +1113,180 @@ describe("Tier TG — the tmux composite", () => { expect(tmux.issued.slice(0, asked).some((line) => line.startsWith("kill-server"))).toBe(false); }); + it("TG14: the control protocol is consumed, never shown to the reader", function* () { + // `@effectionx/process` writes every child's stdout and stderr to this + // process by default, and consuming `client.stdout` does not turn that off + // — the two are independent. So the hidden control client's own records + // (`%session-changed`, `%window-renamed`, `%window-pane-changed`, and every + // other `%` line) were reaching the reader's terminal and any pane prompt + // drawn over it. Nothing about the grid looked wrong; the terminal just had + // protocol on it. + const { written } = yield* useHostStreams(); + + const { grid, tmux } = yield* useComposite({ panes: 1, columns: 1 }); + // Every record the composite classifies, and one it does not, so the claim + // is not limited to the lines this suite happens to care about. + for (const record of [ + "%session-changed $0 xmd", + "%window-renamed @0 pane", + "%window-pane-changed @0 %1", + "%client-detached /dev/ttys999", + ]) { + yield* tmux.say(record); + } + yield* untilEvent(grid, "client-detached"); + + // The records were consumed — the composite classified the one it needed. + expect(grid.events.some((event) => event.kind === "client-detached")).toBe(true); + // And none of them was shown. Asserted on `%` rather than on the four + // strings: what must not reach a terminal is the protocol, not these lines. + const shown = written.join(""); + expect(shown.includes("%session-changed")).toBe(false); + expect(shown.includes("%window-renamed")).toBe(false); + expect(shown.includes("%window-pane-changed")).toBe(false); + expect(shown.includes("%client-detached")).toBe(false); + }); + + it("TG15: a control client that complains says nothing to the reader", function* () { + // The other half of TG14. tmux's own stderr names sockets, sessions and + // panes, so it is this invocation's private topology and never reaches the + // reader — a failing grid is heard through the provider's normalized + // refusal, not through the multiplexer's voice. + const { written, complained } = yield* useHostStreams(); + + const { grid, tmux } = yield* useComposite({ panes: 1, columns: 1 }); + yield* tmux.say("!stderr tmux: no server running on /private/tmp/xmd-grid-abc/s"); + // Ordered behind a record the composite classifies, so the row waits on the + // client having read that far rather than on a duration. + yield* tmux.say("%client-detached /dev/ttys999"); + yield* untilEvent(grid, "client-detached"); + + const shown = written.join("") + complained.join(""); + expect(shown.includes("no server running")).toBe(false); + // And the socket path it named is private: nothing on either stream. + expect(shown.includes("xmd-grid-abc")).toBe(false); + }); + + it("TG16: the very first control record does not reach the reader", function* () { + // The record tmux sends immediately on attach, which is the one with the + // least protection: it is waiting before the composite has read anything. + // + // What this row proves is that it is suppressed, not *where* the + // suppression was installed. That distinction was measured rather than + // assumed: with the handler installed on the handle after `exec()` returns + // this still passes, because the parent installs it synchronously before + // the child is ever scheduled — even with the shell client below, which + // writes within a millisecond instead of the ~100ms this suite's Deno + // fixture spends starting. So the pre-spawn placement in `quietly()` rests + // on the mechanism, not on this row; what this row discriminates is + // suppression being absent, which it catches. + const script = yield* useScript(); + yield* writeTextFile(script, "%session-changed $0 xmd\n"); + const { written } = yield* useHostStreams(); + + // A shell rather than this suite's usual client fixture: it writes its + // record within a millisecond of `exec` instead of after a ~100ms Deno + // start, which is the narrowest window this suite can put a record in. + const tmux = createFakeTmux({ + script, + clientCommand: (mode) => + mode === "control" + ? ["/bin/sh", "-c", "echo '%session-changed $0 xmd'; sleep 30"] + : ["/bin/sh", "-c", "sleep 30"], + }); + yield* useDeadObserver(); + const grid = yield* useTmuxGrid(tmux, { + session: SESSION_MARKER, + columns: 1, + panes: 1, + width: 80, + height: 24, + titles: ["Only"], + workerCommand: () => ["true"], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }); + yield* untilEvent(grid, "other"); + + // Classified — so it really did arrive and really was read. + expect(grid.events.some((event) => event.kind !== "closed")).toBe(true); + expect(written.join("").includes("%session-changed")).toBe(false); + }); + + it("TG17: internal tmux commands show the reader neither output nor error", function* () { + // Every `tmuxAt()` command is internal. A successful one writes its answer + // to stdout, which the provider parses; a failing one writes tmux's own + // complaint to stderr, which the provider turns into `undefined` or into a + // step-named refusal. Neither is the reader's business, and the private + // socket path a real complaint carries is exactly what must not appear. + // + // The tmux here is a program on `PATH` rather than the machine's: what is + // being proved is what this provider forwards, and a row that needed real + // tmux would be a real-tmux gate, which this suite does not have. + const at = yield* useFakeTmuxProgram(); + const { written, complained } = yield* useHostStreams(); + const socket = path.join(tmpdir(), `xmd-quiet-${randomUUID()}`); + const client = tmuxAt(socket, { PATH: at }); + + // Success: the answer is parsed and returned, and stays off the terminal. + expect(yield* client.run(["list-panes"])).toBe(TMUX_STDOUT_MARKER); + + // A soft failure reports nothing rather than throwing. + expect(yield* client.tryRun(["has-session", "-t", "nothing"])).toBe(undefined); + + // A hard failure surfaces the step name and nothing else. + let refusal = ""; + try { + yield* client.run(["has-session", "-t", "nothing"]); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("has-session"); + expect(refusal.includes(socket)).toBe(false); + expect(refusal.includes(TMUX_STDERR_MARKER)).toBe(false); + + const shown = written.join("") + complained.join(""); + expect(shown.includes(TMUX_STDOUT_MARKER)).toBe(false); + expect(shown.includes(TMUX_STDERR_MARKER)).toBe(false); + expect(shown.includes(socket)).toBe(false); + }); + + it("TG18: a whole grid's life leaves no control record on the terminal", function* () { + // Startup, pane switching, detach, server disappearance and teardown, in + // one run, watched at the host's streams. Each step is driven by a record + // the composite classifies, so the row advances on events rather than on a + // duration. + const { written, complained } = yield* useHostStreams(); + const { grid, tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + yield* grid.title(0, "renamed"); + yield* tmux.say("%window-renamed @0 renamed"); + yield* untilEvent(grid, "other"); + yield* tmux.say("%window-pane-changed @0 %1"); + yield* tmux.say("%client-detached /dev/ttys999"); + yield* untilEvent(grid, "client-detached"); + yield* tmux.say("%sessions-changed"); + yield* untilEvent(grid, "sessions-changed"); + const stopped = yield* grid.stop(); + + expect(stopped.gone).toBe(true); + const shown = written.join("") + complained.join(""); + // No record, and no private metadata either: the session name and socket + // this invocation used are its own. + for (const leak of [ + "%session-changed", + "%window-renamed", + "%window-pane-changed", + "%client-detached", + "%sessions-changed", + "%exit", + SESSION_MARKER, + tmux.socket, + ]) { + expect([leak, shown.includes(leak)]).toEqual([leak, false]); + } + }); + it("TG6: reader detach, control loss and server stop are separate events", function* () { const { grid, tmux } = yield* useComposite({ panes: 1, columns: 1 }); @@ -1605,6 +1857,75 @@ describe("Tier TG20 — a pane launch reaches its own worker", () => { expect(yield* exists(`${room}/go`)).toBe(true); }); + it("TG20f: a launch that names no environment inherits the pane's", function* () { + // The defect this row exists for: `runInPane` coerced an absent `env` to + // `{}`, which is not the same instruction. At the root an absent `env` + // means the child inherits, so the pane collapsing it to empty started the + // program with *no* environment — no `TERM`, hence no colour, and no `PATH` + // or `HOME` either. The only production caller of `nativeLaunch` names no + // environment, so this was every real `` into a pane. + // + // TG20a covers a launch that supplies one, and could never have caught it. + const marker = `tg20f-${randomUUID()}`; + process.env.XMD_TG20F = marker; + yield* ensure(() => { + delete process.env.XMD_TG20F; + }); + + // Set before the workers start, because what a pane worker inherits is what + // it hands a child that named no environment. Under the fake that is this + // runner's environment; in production it is the pane's, which tmux gave the + // worker from `paneEnvironment()`. + const { composite } = yield* useLiveComposite(1); + const evidence = path.join(tmpdir(), `xmd-tg20f-${randomUUID()}.txt`); + yield* ensure(function* () { + yield* rm(evidence, { force: true }); + }); + + const outcome = yield* composite.launch( + 0, + { + command: ["/bin/sh", "-c", `printf '%s' "$XMD_TG20F" > "${evidence}"`], + cwd: tmpdir(), + }, + () => {}, + ); + + expect(outcome.exitCode).toBe(0); + expect(yield* readTextFile(evidence)).toBe(marker); + }); + + it("TG20g: an environment that is supplied crosses exactly, gaining nothing", function* () { + // The other half. Inheriting when none was named must not become merging + // when one was: a caller that named an environment gets that environment, + // and no ambient variable of this process joins it. + const marker = `tg20g-${randomUUID()}`; + process.env.XMD_TG20G = marker; + yield* ensure(() => { + delete process.env.XMD_TG20G; + }); + + const { composite } = yield* useLiveComposite(1); + const evidence = path.join(tmpdir(), `xmd-tg20g-${randomUUID()}.txt`); + yield* ensure(function* () { + yield* rm(evidence, { force: true }); + }); + + const outcome = yield* composite.launch( + 0, + { + command: ["/bin/sh", "-c", `printf '%s' "[$XMD_TG20G][$XMD_TG20G_OWN]" > "${evidence}"`], + cwd: tmpdir(), + env: { PATH: "/usr/bin:/bin", XMD_TG20G_OWN: "named" }, + }, + () => {}, + ); + + expect(outcome.exitCode).toBe(0); + // The named entry arrived; the ambient one did not follow it in. + expect(yield* readTextFile(evidence)).toBe("[][named]"); + }); + it("TG20e: a cancelled pane launch does not return while its child lives", function* () { const { composite } = yield* useLiveComposite(1); const room = yield* useScratch(); @@ -1734,7 +2055,7 @@ function useScriptedWorker( const socket = yield* useImpostor(directory, ordinal); const token = (yield* readTextFile(paneTokenPath(directory, ordinal))).trim(); const heard: ToWorker["type"][] = []; - const frames = yield* readFrames(socket, (value) => ToWorkerSchema.parse(value)); + const frames = yield* readFrames(socket, (value) => parseToWorker(value)); yield* writeFrame(socket, { type: "hello", ordinal, @@ -2128,92 +2449,6 @@ describe("Tier TD — the combined teardown", () => { } }); - it("TD9: a teardown that fails refuses the run, and nothing after the grid goes", function* () { - // The document-level end of the same claim: a grid whose teardown could not - // establish the terminal was given back is a failed run, not a run with a - // warning in it. - const room = yield* useScratch(); - const shell = yield* useShellFixture(room); - const script = yield* useScript(); - const invocation = cliCommand([]); - // The server refuses to be killed the first time it is asked, so the last - // phase of the teardown cannot establish it is gone. - const tmux = createFakeTmux({ - script, - clientCommand, - spawnPanes: true, - failOnce: { command: "kill-server", message: "refused" }, - }); - yield* ensure(() => { - tmux.stopPanes(); - }); - yield* writeTextFile( - path.join(room, "doc.md"), - [ - "", - '', - "", - "", - "AFTER_THE_GRID", - "", - ].join("\n"), - ); - yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); - - let outcome: Result | undefined; - let output = ""; - yield* scoped(function* () { - yield* foregroundTerminalGrid({ - isTerminal: () => true, - createTmux: () => tmux, - env: { PATH: "/usr/bin:/bin", SHELL: shell }, - // deno-lint-ignore require-yield - *askVersion() { - return { code: 0, stdout: "tmux 3.6a" }; - }, - workerCommand: function* (ordinal, at) { - return [ - invocation.command, - ...invocation.arguments, - PANE_WORKER_COMMAND, - String(ordinal), - at, - ]; - }, - })(); - - yield* spawn(function* () { - while (!(yield* exists(`${room}/shell-pid`))) { - yield* sleep(15); - } - while (tmux.clients.length === 0) { - yield* sleep(15); - } - yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); - }); - - const execution = yield* execute({ - path: path.join(room, "doc.md"), - stream: new InMemoryStream(), - includes: [room], - }); - const subscription = yield* execution.output; - let next = yield* subscription.next(); - while (!next.done) { - output = next.value; - next = yield* subscription.next(); - } - outcome = yield* execution; - }); - - expect(outcome?.ok).toBe(false); - const refusal = outcome?.ok === false ? String(outcome.error) : ""; - expect(refusal).toContain("terminal server"); - // Nothing private in it, and nothing after the grid ran. - expect(refusal).not.toContain(room); - expect(output).not.toContain("AFTER_THE_GRID"); - }); - it("TD7: the combined order is the frozen one", function* () { const order: string[] = []; let directory = ""; @@ -2258,280 +2493,3 @@ describe("Tier TD — the combined teardown", () => { expect(at("paths-removed")).toBe(order.length - 1); }); }); - -/** One entrypoint's source, for the rows about what a host assembles. */ -function entrypointSource(name: string): Operation { - return readTextFile(path.resolve("packages/cli/src", name)); -} - -describe("Tier TH — host installation", () => { - it("TH1: without a terminal, a grid refuses before anything exists", function* () { - const before = yield* until(readdir(tmpdir())); - let refusal = ""; - try { - yield* scoped(function* () { - yield* installDenoTerminalProcesses(); - yield* useProbedProvider({ isTerminal: () => false }); - }); - } catch (error) { - refusal = error instanceof Error ? error.message : String(error); - } - - expect(refusal).toContain("cannot open a terminal grid"); - expect(refusal).toContain("no terminal"); - // Before a directory, a socket, a token, a worker, a server or a pane: the - // host left nothing behind for having tried. - const after = yield* until(readdir(tmpdir())); - expect(after.filter((name) => name.startsWith("xmd-grid-")).length).toBe( - before.filter((name) => name.startsWith("xmd-grid-")).length, - ); - }); - - it("TH2: without a usable tmux, a grid refuses the same way", function* () { - let refusal = ""; - try { - yield* scoped(function* () { - yield* installDenoTerminalProcesses(); - yield* useProbedProvider({ - isTerminal: () => true, - // A tmux far too old for an explicit layout string. - version: "tmux 1.8", - }); - }); - } catch (error) { - refusal = error instanceof Error ? error.message : String(error); - } - expect(refusal).toContain("cannot open a terminal grid"); - expect(refusal).toContain("older than tmux"); - }); - - it("TH4: the installed SIGHUP listener cancels the run and tears the grid down", function* () { - const room = yield* useScratch(); - const shell = yield* useShellFixture(room); - const script = yield* useScript(); - const invocation = cliCommand([]); - const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); - yield* ensure(() => { - tmux.stopPanes(); - }); - yield* writeTextFile( - path.join(room, "doc.md"), - [ - "", - '', - "", - "", - "AFTER_THE_GRID", - "", - ].join("\n"), - ); - // The run's foreground lease, which a grid takes before any provider. - yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); - - const sighupBefore = foregroundSignalListeners("SIGHUP"); - let directory = ""; - let installed = 0; - let outcome: Result | undefined; - let output = ""; - yield* scoped(function* () { - yield* foregroundTerminalGrid({ - isTerminal: () => true, - createTmux: () => tmux, - env: { PATH: "/usr/bin:/bin", SHELL: shell }, - // deno-lint-ignore require-yield - *askVersion() { - return { code: 0, stdout: "tmux 3.6a" }; - }, - workerCommand: function* (ordinal, at) { - directory = at; - return [ - invocation.command, - ...invocation.arguments, - PANE_WORKER_COMMAND, - String(ordinal), - at, - ]; - }, - })(); - // The listener is the installer's, and this row uses that one. - installed = foregroundSignalListeners("SIGHUP"); - - yield* spawn(function* () { - // Driven by the pane child's own start: the worker spawned, its channel - // authenticated, and the shell it launched said so. - while (!(yield* exists(`${room}/shell-pid`))) { - yield* sleep(15); - } - process.kill(process.pid, "SIGHUP"); - }); - - const execution = yield* execute({ - path: path.join(room, "doc.md"), - stream: new InMemoryStream(), - includes: [room], - }); - const subscription = yield* execution.output; - let next = yield* subscription.next(); - while (!next.done) { - output = next.value; - next = yield* subscription.next(); - } - outcome = yield* execution; - }); - - // The installer put its listener on, and took it off with the run. - expect(installed).toBe(sighupBefore + 1); - expect(foregroundSignalListeners("SIGHUP")).toBe(sighupBefore); - - // Cancellation, not a reader close: the run failed and nothing after the - // grid ran in that attempt. - expect(outcome?.ok).toBe(false); - expect(output).not.toContain("AFTER_THE_GRID"); - - // Every teardown phase completed before the result was observed. The pane's - // child is gone, the worker is gone, the server is gone, and the private - // directory — which is removed last, after its sockets have closed — is - // gone with them. - const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim()); - expect(shellPid).toBeGreaterThan(0); - yield* installDenoTerminalProcesses(); - expect(yield* processReachable(shellPid)).toBe(false); - // Awaited on each process's own exit event, not sampled: a worker that had - // not quite gone yet would make a sampled check pass or fail by timing. - for (const child of tmux.started) { - yield* exited(child); - } - expect(tmux.alive()).toBe(false); - expect(directory).not.toBe(""); - expect(yield* exists(directory)).toBe(false); - }); - - it("TH5: an ordinary run shows the grid, and the reader's detach ends it", function* () { - // The same host, the same document and the same live grid as TH4. What - // differs is the ending: the reader leaves rather than the terminal going - // away, so the grid settles and the document carries on — which is the - // branch `useHangupCancellation()` has to hand the result back through. - const room = yield* useScratch(); - const shell = yield* useShellFixture(room); - const script = yield* useScript(); - const invocation = cliCommand([]); - const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); - yield* ensure(() => { - tmux.stopPanes(); - }); - yield* writeTextFile( - path.join(room, "doc.md"), - [ - "", - '', - "", - "", - "AFTER_THE_GRID", - "", - ].join("\n"), - ); - yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); - - let directory = ""; - let outcome: Result | undefined; - let output = ""; - yield* scoped(function* () { - yield* foregroundTerminalGrid({ - isTerminal: () => true, - createTmux: () => tmux, - env: { PATH: "/usr/bin:/bin", SHELL: shell }, - // deno-lint-ignore require-yield - *askVersion() { - return { code: 0, stdout: "tmux 3.6a" }; - }, - workerCommand: function* (ordinal, at) { - directory = at; - return [ - invocation.command, - ...invocation.arguments, - PANE_WORKER_COMMAND, - String(ordinal), - at, - ]; - }, - })(); - - yield* spawn(function* () { - // Driven by the grid's own progress: the pane child started, and the - // server has a reader's client to report the detach of. No SIGHUP. - while (!(yield* exists(`${room}/shell-pid`))) { - yield* sleep(15); - } - while (tmux.clients.length === 0) { - yield* sleep(15); - } - yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); - }); - - const execution = yield* execute({ - path: path.join(room, "doc.md"), - stream: new InMemoryStream(), - includes: [room], - }); - const subscription = yield* execution.output; - let next = yield* subscription.next(); - while (!next.done) { - output = next.value; - next = yield* subscription.next(); - } - outcome = yield* execution; - }); - - // The exact result, handed back through the hangup wrapper rather than - // swallowed by it: a handler that answered with nothing would be refused - // for having returned before the document produced a result. - expect(outcome).toEqual(Ok("\n\nAFTER_THE_GRID\n")); - // The reader closed the grid; the document went on. - expect(output).toContain("AFTER_THE_GRID"); - - // And it went on over a grid that had actually been taken down: the pane's - // child, the workers, the server and the private directory are all gone. - const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim()); - expect(shellPid).toBeGreaterThan(0); - yield* installDenoTerminalProcesses(); - expect(yield* processReachable(shellPid)).toBe(false); - for (const child of tmux.started) { - yield* exited(child); - } - expect(tmux.alive()).toBe(false); - expect(directory).not.toBe(""); - expect(yield* exists(directory)).toBe(false); - }); - - it("TH6: the Deno and compiled entrypoints present grids; Node and Bun do not", function* () { - for (const name of ["deno.ts", "compiled.ts"]) { - expect((yield* entrypointSource(name)).includes("foregroundTerminalGrid()")).toBe(true); - } - for (const name of ["node.ts", "bun.ts"]) { - // Not a different grid: no grid at all, and therefore the default the - // shared entry declares — which is the installation that validates a grid - // and presents none. - expect((yield* entrypointSource(name)).includes("foregroundTerminalGrid")).toBe(false); - } - expect(yield* entrypointSource("cli.ts")).toContain( - "installTerminalGrid: TerminalGridInstaller = unsupportedTerminalGrid", - ); - }); - - it("TH3: a host that installs no provider still validates the grid", function* () { - // Node and Bun: the same language and the same validation, and core's own - // refusal rather than a provider that half-works. - yield* unsupportedTerminalGrid(); - let refusal = ""; - try { - yield* TerminalGrids.operations.open({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], - }); - } catch (error) { - refusal = error instanceof Error ? error.message : String(error); - } - expect(refusal).toContain("no terminal provider is installed"); - }); -}); diff --git a/packages/terminal/deno.json b/packages/terminal/deno.json new file mode 100644 index 000000000..d8685ae0c --- /dev/null +++ b/packages/terminal/deno.json @@ -0,0 +1,11 @@ +{ + "name": "@executablemd/terminal", + "version": "0.11.0", + "exports": { + ".": "./mod.ts", + "./lifecycle": "./lifecycle.ts", + "./processes": "./processes.ts", + "./posix": "./posix.ts", + "./test": "./testing.ts" + } +} diff --git a/packages/terminal/lifecycle.ts b/packages/terminal/lifecycle.ts new file mode 100644 index 000000000..f0a699ce9 --- /dev/null +++ b/packages/terminal/lifecycle.ts @@ -0,0 +1,53 @@ +/** + * Driving one provider through one grid's life + * (architecture.md §Package ownership). + * + * The direct authority a host installs, the claims and readiness a grid passes + * through before anything is shown, the row-major layout an author's `columns` + * implies, the live and durable grid itself, what it retains, and the + * reader-close boundary that ends it. A facet of `@executablemd/terminal`: what + * it shares with the root is the same object, not a copy. + */ + +export { + awaitReadiness, + createGridRegistry, + createTerminalAuthority, + createTerminalGridClaims, + sealOnTeardown, + TerminalAuthorityError, + terminalInstallation, + useTerminalInstallation, +} from "./src/authority.ts"; +export type { + GridRegistry, + LiveGrid, + PaneReadiness, + TerminalGridAuthority, + TerminalGridClaims, + TerminalInstallation, + TerminalPaneClaim, +} from "./src/authority.ts"; + +export { installTerminalProvider } from "./src/provider-api.ts"; + +export { + createCloseBoundary, + durableGrid, + openTerminalGrid, + paneNeverStartedMessage, + retainedLayout, + toRequest, +} from "./src/grid.ts"; +export type { + CloseBoundary, + GridCloseKind, + PaneStatus, + PaneWork, + RetainedGrid, + RetainedPane, + RetainedPaneOutcome, +} from "./src/grid.ts"; + +export { terminalGridLayout } from "./src/layout.ts"; +export type { PlacedPane, TerminalGridCell, TerminalGridLayout } from "./src/layout.ts"; diff --git a/packages/terminal/mod.ts b/packages/terminal/mod.ts new file mode 100644 index 000000000..6ed7a95db --- /dev/null +++ b/packages/terminal/mod.ts @@ -0,0 +1,71 @@ +/** + * The provider-neutral terminal domain (architecture.md §Package ownership). + * + * Everything here is what a document means by a terminal, independent of what + * presents one: a native launch that wants the foreground, a grid of panes and + * the states they pass through, the routing that finds whichever provider a + * host installed, and the errors a caller meets when none did. No multiplexer, + * socket, process topology or window identifier appears in this package. + * + * The lifecycle a provider is driven through lives in `./lifecycle`, process + * observation in `./processes`, the POSIX adapters in `./posix`, and the + * controlled fixtures that prove the contract in `./test` — facets of one + * package rather than separate definitions, so a symbol exported by two of them + * is the same object. + * + * Those are boundaries in the module graph, not just in the export lists. This + * root, `./lifecycle` and `./processes` reach contracts and operations only: + * nothing they load spawns a process, reads `process.stdout`, or is a test + * fixture. Anything that performs a launch lives behind `./posix`, and anything + * that pretends to behind `./test`, so importing the domain to describe a grid + * pulls in nothing that could present or fake one. + */ + +export { + flushOutput, + NATIVE_LAUNCHER_UNAVAILABLE, + NativeLauncher, + NativeLauncherUnavailableError, + nativeLaunch, + NO_TERMINAL, + reserveTerminal, +} from "./src/native-launcher.ts"; +export type { + NativeLauncherHandler, + NativeLaunchOutcome, + NativeLaunchRequest, +} from "./src/native-launcher.ts"; + +export { + TERMINAL_GRIDS_API, + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalGrids, + TerminalProviderUnavailableError, +} from "./src/composite.ts"; +export type { + TerminalComposite, + TerminalGridApi, + TerminalGridRequest, + TerminalPaneRequest, + TerminalPaneState, + TerminalShellOutcome, +} from "./src/composite.ts"; + +export { + registerTerminalProvider, + TERMINAL_PROVIDERS_API, + TerminalProviderInstallError, + TerminalProviders, +} from "./src/provider-api.ts"; +export type { + TerminalProviderApi, + TerminalProviderCall, + TerminalProviderFactory, + TerminalProviderInstallRequest, + TerminalProviderOptions, +} from "./src/provider-api.ts"; + +export { paneTerminal, usePaneTerminal } from "./src/pane.ts"; +export type { PaneTerminal } from "./src/pane.ts"; +export { usePaneNativeLauncher } from "./src/pane-launcher.ts"; +export type { RunInPane } from "./src/pane-launcher.ts"; diff --git a/packages/terminal/package.json b/packages/terminal/package.json new file mode 100644 index 000000000..568dd92ab --- /dev/null +++ b/packages/terminal/package.json @@ -0,0 +1,21 @@ +{ + "name": "@executablemd/terminal", + "version": "0.11.0", + "description": "The provider-neutral terminal domain for executable.md documents.", + "type": "module", + "exports": { + ".": "./mod.ts", + "./lifecycle": "./lifecycle.ts", + "./processes": "./processes.ts", + "./posix": "./posix.ts", + "./test": "./testing.ts" + }, + "dependencies": { + "@effectionx/context-api": "0.6.0", + "@effectionx/fs": "0.3.0", + "@effectionx/node": "0.2.4", + "@effectionx/process": "0.8.1", + "@executablemd/durable-streams": "workspace:*", + "effection": "4.1.0" + } +} diff --git a/packages/terminal/posix.ts b/packages/terminal/posix.ts new file mode 100644 index 000000000..b2c31bd1b --- /dev/null +++ b/packages/terminal/posix.ts @@ -0,0 +1,18 @@ +/** + * What a POSIX host can actually observe and hand over + * (architecture.md §Package ownership). + * + * The process table, process groups, signals, reachability and terminal holders + * as `ps`, `lsof` and `kill` answer them, plus the foreground child that gives + * a native program this run's own terminal. It lives here rather than in a + * presentation provider because a second POSIX provider should reuse the same + * proof without depending on tmux. + * + * Node and Bun install none of it: a host that cannot observe a pane refuses a + * grid rather than reporting one free it never checked. + */ + +export { installForegroundLauncher } from "./src/posix-launcher.ts"; +export type { ForegroundLauncherOptions } from "./src/posix-launcher.ts"; +export { installDenoTerminalProcesses, posixProcessProbes } from "./src/posix-processes.ts"; +export type { ProcessProbes } from "./src/posix-processes.ts"; diff --git a/packages/terminal/processes.ts b/packages/terminal/processes.ts new file mode 100644 index 000000000..e569c85cd --- /dev/null +++ b/packages/terminal/processes.ts @@ -0,0 +1,31 @@ +/** + * What a host may establish about processes and terminals + * (architecture.md §Package ownership). + * + * The contract only. Every answer is a host's, installed through + * `./posix` or by a suite that supplies its own, and every path fails closed: + * a question that could not be answered is never read as "nothing is there". + */ + +export { + deliverSignal, + descendantsOf, + establishQuiescence, + groupMembers, + paneOccupants, + processReachable, + processTable, + TERMINAL_PROCESSES_API, + TERMINAL_PROCESSES_UNAVAILABLE, + TerminalProcesses, + TerminalProcessesUnavailableError, + terminalHolders, +} from "./src/processes.ts"; +export type { + PaneOccupants, + PaneQuiescence, + ProcessFacts, + SignalDelivery, + TerminalProcessHandler, + TerminalSignal, +} from "./src/processes.ts"; diff --git a/packages/core/src/terminal/authority.ts b/packages/terminal/src/authority.ts similarity index 99% rename from packages/core/src/terminal/authority.ts rename to packages/terminal/src/authority.ts index 64e11fce3..29b15370c 100644 --- a/packages/core/src/terminal/authority.ts +++ b/packages/terminal/src/authority.ts @@ -21,7 +21,7 @@ import { all, createContext, ensure, withResolvers } from "effection"; import type { Context, Operation } from "effection"; -import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; +import type { TerminalComposite, TerminalGridRequest } from "./composite.ts"; export class TerminalAuthorityError extends Error { override name = "TerminalAuthorityError"; diff --git a/packages/runtime/terminal.ts b/packages/terminal/src/composite.ts similarity index 56% rename from packages/runtime/terminal.ts rename to packages/terminal/src/composite.ts index 23a4e5fd5..dc82e1e34 100644 --- a/packages/runtime/terminal.ts +++ b/packages/terminal/src/composite.ts @@ -26,7 +26,7 @@ import { type Api, createApi } from "@effectionx/context-api"; import type { Operation } from "effection"; -import type { NativeLaunchOutcome, NativeLaunchRequest } from "./launcher.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; /** One pane the provider is asked to present, by its authored ordinal. */ export interface TerminalPaneRequest { @@ -206,187 +206,3 @@ export const TerminalGrids: Api = createApi(TE throw new TerminalProviderUnavailableError(); }, }); - -/** - * Everything one controlled composite did, in the order it did it. - * - * The record is the evidence: a suite reads it to prove that preparation came - * before every pane started, that nothing attached before the readiness - * barrier, and that teardown destroyed exactly the composite it prepared. - */ -export interface TerminalProviderLog { - readonly events: string[]; - /** - * What each pane displayed, by ordinal. - * - * A suite reads this to prove where a pane's output went — and reads the root - * document output to prove where it did not. - */ - readonly shown: Map; - /** - * What the provider still holds, counted rather than described. - * - * Each one goes up when the composite takes something and down when it gives - * it back, so a suite reads it after a run to prove nothing was stranded — - * including after a cancellation, where the ordering of the record alone - * would not say whether teardown finished. - */ - readonly live: TerminalProviderResources; -} - -/** What one controlled composite holds at a moment, by kind. */ -export interface TerminalProviderResources { - /** Composites prepared and not yet destroyed. */ - composites: number; - /** Composites attached and not yet destroyed. */ - attached: number; - /** Shells started whose outcome has not been returned. */ - shells: number; - /** Pane launches started whose outcome has not been returned. */ - launches: number; -} - -/** A fresh, empty record. */ -export function terminalProviderLog(): TerminalProviderLog { - return { - events: [], - shown: new Map(), - live: { composites: 0, attached: 0, shells: 0, launches: 0 }, - }; -} - -/** - * What a controlled composite does instead of opening a terminal. - * - * Each hook is a place a suite makes something happen or go wrong: `onPrepare` - * refuses before a composite exists, `onAttach` fails the barrier, `shell` - * decides what a self-closing pane's shell did and whether it started at all, - * and `close` is the operation the grid waits on, so a suite controls exactly - * when the reader leaves. - */ -export interface ControlledCompositeOptions { - /** Appended to as the composite works, so ordering is read rather than timed. */ - readonly log?: TerminalProviderLog; - onPrepare?: (request: TerminalGridRequest) => Operation; - onAttach?: () => Operation; - onDestroy?: () => Operation; - /** - * Called as each pane state is displayed. - * - * A suite watches it to react to something the grid decided — a pane that - * failed, a pane that became runnable — instead of waiting and hoping. - */ - onUpdate?: (ordinal: number, state: TerminalPaneState) => void; - shell?: (ordinal: number, spawned: () => void) => Operation; - /** - * What a pane launch does, in place of starting a native UI. - * - * Left out, a launch refuses — which is what a composite that cannot execute - * one must do, and what keeps a suite that says nothing about launching from - * quietly passing one to the root terminal. - */ - launch?: ( - ordinal: number, - request: NativeLaunchRequest, - spawned: () => void, - ) => Operation; - close?: () => Operation; -} - -/** - * Prepare one composite that presents nothing and records everything. - * - * It answers the whole contract — attach, update, display, shell, close, - * destroy — so a suite exercises core's lifecycle without a terminal, a - * multiplexer, or a process anywhere in it. - */ -export function prepareControlledComposite( - request: TerminalGridRequest, - options: ControlledCompositeOptions = {}, - generation = 0, -): Operation { - return (function* (): Operation { - const log = options.log ?? terminalProviderLog(); - if (options.onPrepare) { - yield* options.onPrepare(request); - } - log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); - log.live.composites++; - let destroyed = false; - let attached = false; - return { - *attach() { - if (options.onAttach) { - yield* options.onAttach(); - } - log.events.push(`attach:${generation}`); - attached = true; - log.live.attached++; - }, - // deno-lint-ignore require-yield - *update(ordinal, state) { - log.events.push(`state:${generation}:${ordinal}:${state}`); - options.onUpdate?.(ordinal, state); - }, - // deno-lint-ignore require-yield - *display(ordinal, text) { - log.shown.set(ordinal, (log.shown.get(ordinal) ?? "") + text); - }, - *shell(ordinal, spawned) { - log.events.push(`shell:${generation}:${ordinal}`); - log.live.shells++; - try { - if (options.shell) { - return yield* options.shell(ordinal, spawned); - } - // The default shell starts: a suite that says nothing about a pane - // wants a pane that works, and one that never reported a spawn would - // hang the readiness barrier instead. - spawned(); - return { exitCode: 0 }; - } finally { - // Counted down however the shell left — returned, thrown, or - // cancelled — because a shell a suite can still find is a shell the - // provider is still holding. - log.live.shells--; - } - }, - *launch(ordinal, request, spawned) { - log.events.push(`launch:${generation}:${ordinal}`); - if (options.launch === undefined) { - throw new Error(`this composite cannot run a native launch in pane ${ordinal}`); - } - log.live.launches++; - try { - return yield* options.launch(ordinal, request, spawned); - } finally { - log.live.launches--; - } - }, - *closed() { - if (options.close) { - yield* options.close(); - } - log.events.push(`closed:${generation}`); - }, - *destroy() { - // Destroying twice would make the record say a composite was taken down - // more times than it was built, which is exactly the ordering claim a - // suite reads this log for. - if (destroyed) { - throw new Error(`controlled composite ${generation} was destroyed twice`); - } - destroyed = true; - if (options.onDestroy) { - yield* options.onDestroy(); - } - log.events.push(`destroy:${generation}`); - log.live.composites--; - if (attached) { - attached = false; - log.live.attached--; - } - }, - }; - })(); -} diff --git a/packages/terminal/src/controlled-composite.ts b/packages/terminal/src/controlled-composite.ts new file mode 100644 index 000000000..ec16db0b8 --- /dev/null +++ b/packages/terminal/src/controlled-composite.ts @@ -0,0 +1,206 @@ +/** + * A composite that presents nothing and records everything. + * + * The controlled implementation of the contract in `./composite.ts`, and the + * authority for core's grid lifecycle: it answers the whole contract — attach, + * update, display, shell, launch, close, destroy — so a suite exercises the + * lifecycle without a terminal, a multiplexer, or a process anywhere in it. + * + * It lives apart from the contract for the same reason the controlled launcher + * does: production code must have no path to a fixture, and importing the + * domain must not load one. It is reachable only through + * `@executablemd/terminal/test`. + */ + +import type { Operation } from "effection"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; +import type { + TerminalComposite, + TerminalGridRequest, + TerminalPaneState, + TerminalShellOutcome, +} from "./composite.ts"; + +/** + * Everything one controlled composite did, in the order it did it. + * + * The record is the evidence: a suite reads it to prove that preparation came + * before every pane started, that nothing attached before the readiness + * barrier, and that teardown destroyed exactly the composite it prepared. + */ +export interface TerminalProviderLog { + readonly events: string[]; + /** + * What each pane displayed, by ordinal. + * + * A suite reads this to prove where a pane's output went — and reads the root + * document output to prove where it did not. + */ + readonly shown: Map; + /** + * What the provider still holds, counted rather than described. + * + * Each one goes up when the composite takes something and down when it gives + * it back, so a suite reads it after a run to prove nothing was stranded — + * including after a cancellation, where the ordering of the record alone + * would not say whether teardown finished. + */ + readonly live: TerminalProviderResources; +} + +/** What one controlled composite holds at a moment, by kind. */ +export interface TerminalProviderResources { + /** Composites prepared and not yet destroyed. */ + composites: number; + /** Composites attached and not yet destroyed. */ + attached: number; + /** Shells started whose outcome has not been returned. */ + shells: number; + /** Pane launches started whose outcome has not been returned. */ + launches: number; +} + +/** A fresh, empty record. */ +export function terminalProviderLog(): TerminalProviderLog { + return { + events: [], + shown: new Map(), + live: { composites: 0, attached: 0, shells: 0, launches: 0 }, + }; +} + +/** + * What a controlled composite does instead of opening a terminal. + * + * Each hook is a place a suite makes something happen or go wrong: `onPrepare` + * refuses before a composite exists, `onAttach` fails the barrier, `shell` + * decides what a self-closing pane's shell did and whether it started at all, + * and `close` is the operation the grid waits on, so a suite controls exactly + * when the reader leaves. + */ +export interface ControlledCompositeOptions { + /** Appended to as the composite works, so ordering is read rather than timed. */ + readonly log?: TerminalProviderLog; + onPrepare?: (request: TerminalGridRequest) => Operation; + onAttach?: () => Operation; + onDestroy?: () => Operation; + /** + * Called as each pane state is displayed. + * + * A suite watches it to react to something the grid decided — a pane that + * failed, a pane that became runnable — instead of waiting and hoping. + */ + onUpdate?: (ordinal: number, state: TerminalPaneState) => void; + shell?: (ordinal: number, spawned: () => void) => Operation; + /** + * What a pane launch does, in place of starting a native UI. + * + * Left out, a launch refuses — which is what a composite that cannot execute + * one must do, and what keeps a suite that says nothing about launching from + * quietly passing one to the root terminal. + */ + launch?: ( + ordinal: number, + request: NativeLaunchRequest, + spawned: () => void, + ) => Operation; + close?: () => Operation; +} + +/** + * Prepare one composite that presents nothing and records everything. + * + * It answers the whole contract — attach, update, display, shell, close, + * destroy — so a suite exercises core's lifecycle without a terminal, a + * multiplexer, or a process anywhere in it. + */ +export function prepareControlledComposite( + request: TerminalGridRequest, + options: ControlledCompositeOptions = {}, + generation = 0, +): Operation { + return (function* (): Operation { + const log = options.log ?? terminalProviderLog(); + if (options.onPrepare) { + yield* options.onPrepare(request); + } + log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + log.live.composites++; + let destroyed = false; + let attached = false; + return { + *attach() { + if (options.onAttach) { + yield* options.onAttach(); + } + log.events.push(`attach:${generation}`); + attached = true; + log.live.attached++; + }, + // deno-lint-ignore require-yield + *update(ordinal, state) { + log.events.push(`state:${generation}:${ordinal}:${state}`); + options.onUpdate?.(ordinal, state); + }, + // deno-lint-ignore require-yield + *display(ordinal, text) { + log.shown.set(ordinal, (log.shown.get(ordinal) ?? "") + text); + }, + *shell(ordinal, spawned) { + log.events.push(`shell:${generation}:${ordinal}`); + log.live.shells++; + try { + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn would + // hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + } finally { + // Counted down however the shell left — returned, thrown, or + // cancelled — because a shell a suite can still find is a shell the + // provider is still holding. + log.live.shells--; + } + }, + *launch(ordinal, request, spawned) { + log.events.push(`launch:${generation}:${ordinal}`); + if (options.launch === undefined) { + throw new Error(`this composite cannot run a native launch in pane ${ordinal}`); + } + log.live.launches++; + try { + return yield* options.launch(ordinal, request, spawned); + } finally { + log.live.launches--; + } + }, + *closed() { + if (options.close) { + yield* options.close(); + } + log.events.push(`closed:${generation}`); + }, + *destroy() { + // Destroying twice would make the record say a composite was taken down + // more times than it was built, which is exactly the ordering claim a + // suite reads this log for. + if (destroyed) { + throw new Error(`controlled composite ${generation} was destroyed twice`); + } + destroyed = true; + if (options.onDestroy) { + yield* options.onDestroy(); + } + log.events.push(`destroy:${generation}`); + log.live.composites--; + if (attached) { + attached = false; + log.live.attached--; + } + }, + }; + })(); +} diff --git a/packages/terminal/src/controlled-launcher.ts b/packages/terminal/src/controlled-launcher.ts new file mode 100644 index 000000000..8b314874f --- /dev/null +++ b/packages/terminal/src/controlled-launcher.ts @@ -0,0 +1,84 @@ +/** + * The launcher a host installs when it has no terminal to give away. + * + * The other implementation of the contract in `./native-launcher.ts`, and the + * one every suite that is not about a real terminal uses. It reaches no + * process and no host stream — a launch here is whatever the row says it is — + * and it lives in its own module so that importing the domain never loads a + * fixture. Production code has no path to it: it is reachable only through + * `@executablemd/terminal/test`. + */ + +import { resource } from "effection"; +import type { Operation } from "effection"; +import { NativeLauncher } from "./native-launcher.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; + +/** + * How a controlled launch behaves. + * + * `record` sees each request in the order the provider made it; `outcome` + * decides what the child did; and `wait` is the operation the launch blocks + * on, so a test controls exactly how long the document stays suspended. + */ +export interface ControlledLauncherOptions { + record?: (request: NativeLaunchRequest) => void; + outcome?: (request: NativeLaunchRequest) => NativeLaunchOutcome; + wait?: (request: NativeLaunchRequest) => Operation; + /** + * Start the child, in place of a runtime that would. + * + * It receives the spawn report, so a test decides whether this launch starts + * at all: reporting is what a successful start does, and throwing without + * reporting is what a failure before the start does. Left out, the child + * starts at once — a test that says nothing about starting wants a launch + * that started. + */ + start?: (request: NativeLaunchRequest, spawned: () => void) => Operation; + onReserve?: () => void; + onFlush?: () => void; +} + +export function* installControlledLauncher( + options: ControlledLauncherOptions = {}, +): Operation { + let held = false; + yield* NativeLauncher.around( + { + reserve() { + return resource(function* (provide) { + if (held) { + throw new Error( + "another already holds this run's terminal — one " + + "native UI owns the terminal at a time", + ); + } + held = true; + options.onReserve?.(); + try { + yield* provide(); + } finally { + held = false; + } + }); + }, + // deno-lint-ignore require-yield + *flush() { + options.onFlush?.(); + }, + *launch([request, spawned]) { + options.record?.(request); + if (options.start) { + yield* options.start(request, spawned); + } else { + spawned(); + } + if (options.wait) { + yield* options.wait(request); + } + return options.outcome?.(request) ?? { exitCode: 0 }; + }, + }, + { at: "min" }, + ); +} diff --git a/packages/core/src/terminal/grid.ts b/packages/terminal/src/grid.ts similarity index 98% rename from packages/core/src/terminal/grid.ts rename to packages/terminal/src/grid.ts index f96fb091a..f9a6750f6 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/terminal/src/grid.ts @@ -42,8 +42,9 @@ import { ephemeral, } from "@executablemd/durable-streams"; import type { Json, Workflow } from "@executablemd/durable-streams"; -import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; -import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; +import { TerminalGrids } from "./composite.ts"; +import { flushOutput, reserveTerminal } from "./native-launcher.ts"; +import type { TerminalComposite, TerminalGridRequest } from "./composite.ts"; import { awaitReadiness, @@ -52,7 +53,7 @@ import { terminalInstallation, } from "./authority.ts"; import type { LiveGrid, TerminalPaneClaim } from "./authority.ts"; -import type { TerminalGridLayout } from "../terminal-grid.ts"; +import type { TerminalGridLayout } from "./layout.ts"; /** * The live boundary reader close crosses (architecture.md §Atomic presentation diff --git a/packages/core/src/terminal-grid.ts b/packages/terminal/src/layout.ts similarity index 85% rename from packages/core/src/terminal-grid.ts rename to packages/terminal/src/layout.ts index 59a08a920..8022dd3be 100644 --- a/packages/core/src/terminal-grid.ts +++ b/packages/terminal/src/layout.ts @@ -12,7 +12,14 @@ * rows that many panes fill, and which cell each pane occupies. */ -import type { TerminalPane } from "./structural-rules.ts"; +/** + * Whether a pane runs the markdown it holds or the host's default shell. + * + * Declared here rather than imported: the layout is provider-neutral data, and + * core's authored pane — which carries the element it was written as — would + * point this package back at the document engine it is placed for. + */ +export type PaneForm = "paired" | "self-closing"; /** One pane, placed. */ export interface TerminalGridCell { @@ -25,7 +32,7 @@ export interface TerminalGridCell { /** The label it displays. Two cells may carry the same one. */ readonly title: string; /** Whether it runs the markdown the pane holds or the host's default shell. */ - readonly form: TerminalPane["form"]; + readonly form: PaneForm; } /** The complete grid one `` asked for. */ @@ -40,7 +47,7 @@ export interface TerminalGridLayout { /** One pane's placeable facts, once its title has been resolved. */ export interface PlacedPane { readonly title: string; - readonly form: TerminalPane["form"]; + readonly form: PaneForm; } /** diff --git a/packages/terminal/src/native-launcher.ts b/packages/terminal/src/native-launcher.ts new file mode 100644 index 000000000..df2e6f29b --- /dev/null +++ b/packages/terminal/src/native-launcher.ts @@ -0,0 +1,139 @@ +/** + * The native launcher contract — how a host hands one child process the + * terminal, and nothing about how any particular host does it. + * + * This is not `exec`. An ordinary command is a captured child: its stdout and + * stderr are piped so a document can display, capture and journal them, and + * its exit status is a value the document reads. A native coding-agent UI is + * the opposite of that. It draws on the terminal, reads the person's + * keystrokes, and owns the conversation it has with them. None of that may + * become an XMD process result or a journaled transcript, and a piped child + * cannot be interactive at all. + * + * So a launch asks for three things in order, and each is refusable on its + * own: + * + * 1. `reserve()` takes the one foreground-terminal lease for the run. A host + * with no terminal refuses here, which is before any session ownership has + * moved. Two launches cannot hold it at once even when they name different + * sessions, so native UIs are sequential by construction. + * 2. `flush()` gives the reader everything the document has produced so far, + * so the native UI does not open on top of half-written output. + * 3. `launch()` spawns the child with the terminal inherited, waits for it, + * and reports its terminal status and nothing else. + * + * There is no host default. `xmd run` installs the foreground launcher from + * `./posix-launcher.ts`; a test or embedding host installs the controlled one + * from `./controlled-launcher.ts`. Until one is installed every operation + * refuses, which is what keeps document help and inspection free of any of + * this. + * + * Nothing here reaches a process, a stream or a host API, and that separation + * is the point rather than a tidiness: this module is what the package root + * exports, so importing the domain does not load `node:child_process`. A + * consumer that only describes a launch pulls in nothing that could perform + * one. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +/** + * What a provider asks the host to run. + * + * `command` is the complete argv, built by the provider's adapter from the + * provider-native session identity. Raw prepared instructions never appear in + * it, and never in `env`: a process's arguments and environment are readable + * by other processes, so the instruction layer travels through the provider's + * own session API instead. + */ +export interface NativeLaunchRequest { + command: string[]; + cwd: string; + env?: Record; +} + +/** + * How the native UI ended. A child that exited on a signal reports the signal + * and no code, which is how a signalled exit stays distinguishable from + * status 0. + */ +export interface NativeLaunchOutcome { + exitCode?: number; + signal?: string; +} + +export interface NativeLauncherHandler { + reserve(): Operation; + flush(): Operation; + /** + * Start the native UI, wait for it, and report how it ended. + * + * `spawned` is the runtime's child-start event, reported as a parameter + * rather than through the request or the result. A host calls it once the + * child has actually started and before it waits for the exit, so a UI that + * starts and closes at once has still started. Preparation, a reservation, an + * allocated PID and the child's first output are not that event, and a launch + * that never starts never calls it. + * + * At the root nobody is listening and it does nothing. Composed middleware — + * a terminal pane's launcher — is what gives it a meaning, which is why it + * travels here instead of in `NativeLaunchRequest`. + */ + launch(request: NativeLaunchRequest, spawned: () => void): Operation; +} + +export const NATIVE_LAUNCHER_UNAVAILABLE = + "no native launcher is installed — this host does not hand a native agent UI " + + "the terminal. `xmd run` installs one; a test or embedding host installs its own."; + +export class NativeLauncherUnavailableError extends Error { + override name = "NativeLauncherUnavailableError"; + constructor(message: string = NATIVE_LAUNCHER_UNAVAILABLE) { + super(message); + } +} + +export const NativeLauncher: Api = createApi( + "runtime.nativeLauncher", + { + // deno-lint-ignore require-yield + *reserve(): Operation { + throw new NativeLauncherUnavailableError(); + }, + // deno-lint-ignore require-yield + *flush(): Operation { + throw new NativeLauncherUnavailableError(); + }, + // deno-lint-ignore require-yield + *launch(_request: NativeLaunchRequest, _spawned: () => void): Operation { + throw new NativeLauncherUnavailableError(); + }, + }, +); + +/** Hold the foreground-terminal lease for the calling scope. */ +export function reserveTerminal(): Operation { + return NativeLauncher.operations.reserve(); +} + +/** Give the reader everything the document has produced so far. */ +export function flushOutput(): Operation { + return NativeLauncher.operations.flush(); +} + +/** + * Run one native UI as a foreground child and report how it ended. + * + * A provider adapter calls this and hears nothing about the child's start: the + * spawn event is the host's to report and a pane's to act on, and an adapter + * that could observe it could also fake it. + */ +export function nativeLaunch(request: NativeLaunchRequest): Operation { + return NativeLauncher.operations.launch(request, () => {}); +} + +export const NO_TERMINAL = + " needs a terminal: a native agent UI reads keystrokes and " + + "draws on the screen, and this invocation has none. Run xmd from a terminal, " + + "or use a host that installs its own launcher."; diff --git a/packages/core/src/terminal/pane-launcher.ts b/packages/terminal/src/pane-launcher.ts similarity index 96% rename from packages/core/src/terminal/pane-launcher.ts rename to packages/terminal/src/pane-launcher.ts index bce408d0f..0426800d9 100644 --- a/packages/core/src/terminal/pane-launcher.ts +++ b/packages/terminal/src/pane-launcher.ts @@ -22,8 +22,8 @@ import { resource } from "effection"; import type { Operation } from "effection"; -import { NativeLauncher } from "@executablemd/runtime"; -import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/runtime"; +import { NativeLauncher } from "./native-launcher.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; import type { TerminalPaneClaim } from "./authority.ts"; diff --git a/packages/core/src/terminal/pane.ts b/packages/terminal/src/pane.ts similarity index 100% rename from packages/core/src/terminal/pane.ts rename to packages/terminal/src/pane.ts diff --git a/packages/runtime/launcher.ts b/packages/terminal/src/posix-launcher.ts similarity index 58% rename from packages/runtime/launcher.ts rename to packages/terminal/src/posix-launcher.ts index 40068cf2e..75d6029ee 100644 --- a/packages/runtime/launcher.ts +++ b/packages/terminal/src/posix-launcher.ts @@ -1,139 +1,28 @@ /** - * The native launcher — how a host hands one child process the terminal. + * The POSIX foreground launcher — how *this* host hands a child the terminal. * - * This is not `exec`. An ordinary command is a captured child: its stdout and - * stderr are piped so a document can display, capture and journal them, and - * its exit status is a value the document reads. A native coding-agent UI is - * the opposite of that. It draws on the terminal, reads the person's - * keystrokes, and owns the conversation it has with them. None of that may - * become an XMD process result or a journaled transcript, and a piped child - * cannot be interactive at all. + * One of two implementations of the contract in `./native-launcher.ts`, and + * the only one that reaches a process. It lives apart from that contract + * because a consumer that merely describes a launch must not load + * `node:child_process` to do it: the package root exports the contract, and + * this module is reachable only through `@executablemd/terminal/posix`. * - * So a launch asks for three things in order, and each is refusable on its - * own: - * - * 1. `reserve()` takes the one foreground-terminal lease for the run. A host - * with no terminal refuses here, which is before any session ownership has - * moved. Two launches cannot hold it at once even when they name different - * sessions, so native UIs are sequential by construction. - * 2. `flush()` gives the reader everything the document has produced so far, - * so the native UI does not open on top of half-written output. - * 3. `launch()` spawns the child with the terminal inherited, waits for it, - * and reports its terminal status and nothing else. - * - * There is no host default. `xmd run` installs the foreground launcher; - * a test or embedding host installs a controlled one that needs no terminal. - * Until one is installed every operation refuses, which is what keeps - * document help and inspection free of any of this. + * XMD stays the parent. It does not replace itself with the child, because a + * process that has execed away cannot cancel the document, reap the child, own + * its exit status, or continue after the UI closes. What follows is that + * parenthood made good: a bounded interrupt escalation, a reap that establishes + * the child is actually gone, and a drain that keeps the UI from opening on top + * of half-written output. */ -import { type Api, createApi } from "@effectionx/context-api"; import { ensure, race, resource, scoped, until } from "effection"; import { once } from "@effectionx/node/events"; import type { Operation } from "effection"; import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import process from "node:process"; - -/** - * What a provider asks the host to run. - * - * `command` is the complete argv, built by the provider's adapter from the - * provider-native session identity. Raw prepared instructions never appear in - * it, and never in `env`: a process's arguments and environment are readable - * by other processes, so the instruction layer travels through the provider's - * own session API instead. - */ -export interface NativeLaunchRequest { - command: string[]; - cwd: string; - env?: Record; -} - -/** - * How the native UI ended. A child that exited on a signal reports the signal - * and no code, which is how a signalled exit stays distinguishable from - * status 0. - */ -export interface NativeLaunchOutcome { - exitCode?: number; - signal?: string; -} - -export interface NativeLauncherHandler { - reserve(): Operation; - flush(): Operation; - /** - * Start the native UI, wait for it, and report how it ended. - * - * `spawned` is the runtime's child-start event, reported as a parameter - * rather than through the request or the result. A host calls it once the - * child has actually started and before it waits for the exit, so a UI that - * starts and closes at once has still started. Preparation, a reservation, an - * allocated PID and the child's first output are not that event, and a launch - * that never starts never calls it. - * - * At the root nobody is listening and it does nothing. Composed middleware — - * a terminal pane's launcher — is what gives it a meaning, which is why it - * travels here instead of in `NativeLaunchRequest`. - */ - launch(request: NativeLaunchRequest, spawned: () => void): Operation; -} - -export const NATIVE_LAUNCHER_UNAVAILABLE = - "no native launcher is installed — this host does not hand a native agent UI " + - "the terminal. `xmd run` installs one; a test or embedding host installs its own."; - -export class NativeLauncherUnavailableError extends Error { - override name = "NativeLauncherUnavailableError"; - constructor(message: string = NATIVE_LAUNCHER_UNAVAILABLE) { - super(message); - } -} - -export const NativeLauncher: Api = createApi( - "runtime.nativeLauncher", - { - // deno-lint-ignore require-yield - *reserve(): Operation { - throw new NativeLauncherUnavailableError(); - }, - // deno-lint-ignore require-yield - *flush(): Operation { - throw new NativeLauncherUnavailableError(); - }, - // deno-lint-ignore require-yield - *launch(_request: NativeLaunchRequest, _spawned: () => void): Operation { - throw new NativeLauncherUnavailableError(); - }, - }, -); - -/** Hold the foreground-terminal lease for the calling scope. */ -export function reserveTerminal(): Operation { - return NativeLauncher.operations.reserve(); -} - -/** Give the reader everything the document has produced so far. */ -export function flushOutput(): Operation { - return NativeLauncher.operations.flush(); -} - -/** - * Run one native UI as a foreground child and report how it ended. - * - * A provider adapter calls this and hears nothing about the child's start: the - * spawn event is the host's to report and a pane's to act on, and an adapter - * that could observe it could also fake it. - */ -export function nativeLaunch(request: NativeLaunchRequest): Operation { - return NativeLauncher.operations.launch(request, () => {}); -} - -export const NO_TERMINAL = - " needs a terminal: a native agent UI reads keystrokes and " + - "draws on the screen, and this invocation has none. Run xmd from a terminal, " + - "or use a host that installs its own launcher."; +import { NativeLauncher, NativeLauncherUnavailableError, NO_TERMINAL } from "./native-launcher.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; /** * How long an interrupted child is given to leave on its own before the @@ -148,7 +37,7 @@ const REAP_POLL_MS = 25; /** How long an unanswerable kill is given before the child is called gone. */ const KILL_SETTLE_MS = 500; -interface ForegroundLauncherOptions { +export interface ForegroundLauncherOptions { /** * Whether this host can hand a child the terminal. Read once, when the * launcher installs, so a run learns what it is before a document starts. @@ -428,73 +317,3 @@ function isReachable(pid: number): boolean { return false; } } - -/** - * A launcher a host installs when it has no terminal to give away, and no - * intention of starting a native UI. - * - * `record` sees each request in the order the provider made it; `outcome` - * decides what the child did; and `wait` is the operation the launch blocks - * on, so a test controls exactly how long the document stays suspended. - */ -export interface ControlledLauncherOptions { - record?: (request: NativeLaunchRequest) => void; - outcome?: (request: NativeLaunchRequest) => NativeLaunchOutcome; - wait?: (request: NativeLaunchRequest) => Operation; - /** - * Start the child, in place of a runtime that would. - * - * It receives the spawn report, so a test decides whether this launch starts - * at all: reporting is what a successful start does, and throwing without - * reporting is what a failure before the start does. Left out, the child - * starts at once — a test that says nothing about starting wants a launch - * that started. - */ - start?: (request: NativeLaunchRequest, spawned: () => void) => Operation; - onReserve?: () => void; - onFlush?: () => void; -} - -export function* installControlledLauncher( - options: ControlledLauncherOptions = {}, -): Operation { - let held = false; - yield* NativeLauncher.around( - { - reserve() { - return resource(function* (provide) { - if (held) { - throw new Error( - "another already holds this run's terminal — one " + - "native UI owns the terminal at a time", - ); - } - held = true; - options.onReserve?.(); - try { - yield* provide(); - } finally { - held = false; - } - }); - }, - // deno-lint-ignore require-yield - *flush() { - options.onFlush?.(); - }, - *launch([request, spawned]) { - options.record?.(request); - if (options.start) { - yield* options.start(request, spawned); - } else { - spawned(); - } - if (options.wait) { - yield* options.wait(request); - } - return options.outcome?.(request) ?? { exitCode: 0 }; - }, - }, - { at: "min" }, - ); -} diff --git a/packages/runtime/deno-terminal-processes.ts b/packages/terminal/src/posix-processes.ts similarity index 99% rename from packages/runtime/deno-terminal-processes.ts rename to packages/terminal/src/posix-processes.ts index 375715416..1e9e4067b 100644 --- a/packages/runtime/deno-terminal-processes.ts +++ b/packages/terminal/src/posix-processes.ts @@ -23,8 +23,8 @@ import { until } from "effection"; import type { Operation } from "effection"; import { execFile } from "node:child_process"; import process from "node:process"; -import { TerminalProcesses, TerminalProcessesUnavailableError } from "./terminal-processes.ts"; -import type { ProcessFacts, SignalDelivery, TerminalSignal } from "./terminal-processes.ts"; +import { TerminalProcesses, TerminalProcessesUnavailableError } from "./processes.ts"; +import type { ProcessFacts, SignalDelivery, TerminalSignal } from "./processes.ts"; /** What one observation ran, so a suite can answer for it. */ export interface ProcessProbes { diff --git a/packages/runtime/terminal-processes.ts b/packages/terminal/src/processes.ts similarity index 100% rename from packages/runtime/terminal-processes.ts rename to packages/terminal/src/processes.ts diff --git a/packages/core/src/terminal/provider-api.ts b/packages/terminal/src/provider-api.ts similarity index 100% rename from packages/core/src/terminal/provider-api.ts rename to packages/terminal/src/provider-api.ts diff --git a/packages/terminal/testing.ts b/packages/terminal/testing.ts new file mode 100644 index 000000000..81b12e0be --- /dev/null +++ b/packages/terminal/testing.ts @@ -0,0 +1,22 @@ +/** + * Controlled surfaces that prove the neutral contract without a provider + * (architecture.md §Package ownership). + * + * A launcher that hands out no terminal, a composite that presents nothing, and + * a log whose counters are the evidence a lifecycle row reads. Production code + * imports none of it; these exist so core lifecycle semantics can be proved + * without tmux, a terminal, or a subprocess. + * + * The export is `./test`; the file is `testing.ts` because Deno's own test-file + * pattern matches a bare `test.ts`, which would make the test runner load this + * entrypoint as a test file in every shard. + */ + +export { installControlledLauncher } from "./src/controlled-launcher.ts"; +export type { ControlledLauncherOptions } from "./src/controlled-launcher.ts"; +export { prepareControlledComposite, terminalProviderLog } from "./src/controlled-composite.ts"; +export type { + ControlledCompositeOptions, + TerminalProviderLog, + TerminalProviderResources, +} from "./src/controlled-composite.ts"; diff --git a/packages/runtime/tests/native-launcher.test.ts b/packages/terminal/tests/native-launcher.test.ts similarity index 99% rename from packages/runtime/tests/native-launcher.test.ts rename to packages/terminal/tests/native-launcher.test.ts index 1d0bc69de..76f4584ea 100644 --- a/packages/runtime/tests/native-launcher.test.ts +++ b/packages/terminal/tests/native-launcher.test.ts @@ -25,13 +25,13 @@ import process from "node:process"; import { spawn as spawnChild } from "node:child_process"; import { flushOutput, - installForegroundLauncher, nativeLaunch, NativeLauncher, NO_TERMINAL, reap, reserveTerminal, -} from "../launcher.ts"; +} from "../src/native-launcher.ts"; +import { installForegroundLauncher } from "../src/posix-launcher.ts"; const SENTINEL = "SENTINEL-PREPARED-CONTEXT-4b17"; diff --git a/packages/terminal/tests/package-boundary.test.ts b/packages/terminal/tests/package-boundary.test.ts new file mode 100644 index 000000000..a52d591b3 --- /dev/null +++ b/packages/terminal/tests/package-boundary.test.ts @@ -0,0 +1,422 @@ +/** + * Tier TG21 — the package boundary, and the absence of the paths it replaced + * (architecture.md §Package ownership, DEC-016). + * + * The stack has not merged, so the terminal exports that used to sit in + * runtime, core and CLI were never a compatibility surface — they were the + * ownership ambiguity this extraction removes. They are gone, and these rows + * are what keeps them gone. + * + * Three claims, each failing differently if the extraction regresses. + * + * Structural: the dependency arrows point at the neutral domain, so a provider + * can be written without CLI or tmux and the domain consumed without either. + * A violation is an import statement, so the evidence is the import statements + * themselves — read from the production sources rather than inferred from a + * manifest, because a manifest records what was declared and a source records + * what is actually reached. + * + * Absence: the old modules, the old exports and the old CLI implementation + * path are not merely unused but not there. An unused forwarding barrel is + * exactly the thing that lets an import drift back. + * + * Uniqueness: each contextual descriptor and public error constructor is + * defined once. These are matched with `instanceof` and carry middleware, so a + * second definition would not fail loudly — it would split composition between + * two objects that behave alike, which is the failure this tier exists to make + * impossible rather than merely unlikely. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { exists, readTextFile } from "@effectionx/fs"; +import { readdir } from "node:fs/promises"; +import * as path from "node:path"; +import { until } from "effection"; +import type { Operation } from "effection"; + +/** + * Everything one entrypoint loads, transitively. + * + * Read from the module graph rather than from the entrypoint's own export + * list, because an export list is exactly what hid this: re-exporting three + * names out of a module that also spawns processes narrows what is *reachable + * by name* and nothing about what is *loaded*. A facade passes an export-shape + * check and fails this one. + */ +function* graphOf(entrypoint: string): Operation { + const seen = new Set(); + const pending = [path.resolve("packages/terminal", entrypoint)]; + while (pending.length > 0) { + const file = pending.pop(); + if (file === undefined || seen.has(file)) { + continue; + } + seen.add(file); + const source = yield* readTextFile(file); + for (const match of source.matchAll(/from\s+"([^"]+)"/g)) { + const specifier = match[1]; + if (specifier === undefined) { + continue; + } + if (specifier.startsWith("node:")) { + seen.add(specifier); + continue; + } + if (specifier.startsWith(".")) { + pending.push(path.resolve(path.dirname(file), specifier)); + } + } + } + return [...seen]; +} + +/** + * Trees that are an installer's rather than this repository's. + * + * `node_modules` has to go, and not only for speed: a workspace install links + * every dependency package under its dependents, so `packages/terminal-tmux/ + * node_modules/@executablemd/terminal/src/...` is the *same file* reached + * through a link. Walking it would count one definition many times and would + * read a vendored copy's imports as if they were the importing package's own — + * so a package would appear to import whatever its dependencies import. Bun's + * layout creates those links and Deno's does not, which is why this was + * invisible until the Bun shard ran. + */ +const INSTALLED = new Set(["node_modules", "npm", "dist", "generated", "vendor"]); + +/** Whether any segment of `relative` names a tree this repository does not author. */ +function installed(relative: string): boolean { + return relative.split(path.sep).some((segment) => INSTALLED.has(segment)); +} + +/** Every production source of one workspace package, tests excluded. */ +function* productionSources(pkg: string): Operation { + const root = path.resolve("packages", pkg); + const files: string[] = []; + const entries = yield* until(readdir(root, { recursive: true, withFileTypes: true })); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".ts")) { + continue; + } + const full = path.join(entry.parentPath ?? root, entry.name); + const relative = path.relative(root, full); + if (installed(relative)) { + continue; + } + // Tests prove the contract; they do not define the shipped graph. A row may + // reach across packages to drive a fixture without that being a dependency + // of the artifact. + if (relative.startsWith("tests/") || relative.includes(".test.")) { + continue; + } + files.push(full); + } + return files; +} + +/** The package specifiers one source imports from, bare names only. */ +function specifiersOf(source: string): string[] { + const found: string[] = []; + for (const match of source.matchAll(/(?:^|\n)\s*(?:import|export)[^;]*?from\s+"([^"]+)"/g)) { + const specifier = match[1]; + if (specifier !== undefined && !specifier.startsWith(".")) { + found.push(specifier); + } + } + return found; +} + +/** Which workspace packages `pkg`'s production code actually imports. */ +function* importsOf(pkg: string): Operation> { + const reached = new Set(); + for (const file of yield* productionSources(pkg)) { + for (const specifier of specifiersOf(yield* readTextFile(file))) { + if (specifier.startsWith("@executablemd/")) { + // `@executablemd/terminal/posix` is the terminal package. + reached.add(specifier.split("/").slice(0, 2).join("/")); + } + } + } + return reached; +} + +/** Every `.ts` file in the repository's packages, tests included. */ +function* everySource(): Operation { + const root = path.resolve("packages"); + const files: string[] = []; + const entries = yield* until(readdir(root, { recursive: true, withFileTypes: true })); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".ts")) { + continue; + } + const full = path.join(entry.parentPath ?? root, entry.name); + if (installed(path.relative(root, full))) { + continue; + } + files.push(full); + } + return files; +} + +/** The names the terminal domain owns, whatever path someone might reach for. */ +const TERMINAL_EXPORTS = [ + "NativeLauncher", + "nativeLaunch", + "reserveTerminal", + "flushOutput", + "installForegroundLauncher", + "installControlledLauncher", + "TerminalGrids", + "TerminalProviders", + "TerminalProcesses", + "registerTerminalProvider", + "installTerminalProvider", + "useTerminalInstallation", + "paneTerminal", + "prepareControlledComposite", + "terminalProviderLog", + "installDenoTerminalProcesses", + "processTable", + "processReachable", +] as const; + +describe("Tier TG21 — the terminal package boundary", () => { + it("TG21a: the neutral domain reaches no engine, host or provider", function* () { + const reached = yield* importsOf("terminal"); + // The whole point of the extraction: a provider or a consumer takes the + // domain without taking the document engine, the CLI, or tmux with it. + for (const forbidden of [ + "@executablemd/runtime", + "@executablemd/core", + "@executablemd/cli", + "@executablemd/terminal-tmux", + ]) { + expect([forbidden, reached.has(forbidden)]).toEqual([forbidden, false]); + } + }); + + it("TG21b: the tmux adapter reaches the domain and nothing above it", function* () { + const reached = yield* importsOf("terminal-tmux"); + expect(reached.has("@executablemd/terminal")).toBe(true); + for (const forbidden of ["@executablemd/runtime", "@executablemd/core", "@executablemd/cli"]) { + expect([forbidden, reached.has(forbidden)]).toEqual([forbidden, false]); + } + }); + + it("TG21c: runtime owns no terminal dependency, and only CLI composes both", function* () { + // The amendment's load-bearing change: runtime keeps no terminal edge at + // all, in its sources or its manifest, because there is no unreleased path + // left for it to keep alive. + expect((yield* importsOf("runtime")).has("@executablemd/terminal")).toBe(false); + const manifest = yield* readTextFile(path.resolve("packages/runtime/package.json")); + expect(manifest.includes("@executablemd/terminal")).toBe(false); + + expect((yield* importsOf("core")).has("@executablemd/terminal")).toBe(true); + // Core is the document engine, not a host: it never selects a provider. + expect((yield* importsOf("core")).has("@executablemd/terminal-tmux")).toBe(false); + const cli = yield* importsOf("cli"); + for (const required of [ + "@executablemd/core", + "@executablemd/runtime", + "@executablemd/terminal", + "@executablemd/terminal-tmux", + ]) { + expect([required, cli.has(required)]).toEqual([required, true]); + } + }); + + it("TG21i: the neutral entrypoints load no host process code and no fixture", function* () { + // The defect this replaced: the root re-exported a handful of neutral names + // from a module that also spawned children and carried a test double, so + // importing the domain loaded `node:child_process` and a fixture. Selective + // re-export narrows the names, never the load. + for (const entrypoint of ["mod.ts", "lifecycle.ts", "processes.ts"]) { + const graph = yield* graphOf(entrypoint); + const host = graph.filter( + (module) => + module === "node:child_process" || + module === "node:process" || + module.endsWith("/posix-launcher.ts") || + module.endsWith("/posix-processes.ts"), + ); + const fixtures = graph.filter((module) => module.includes("/controlled-")); + expect([entrypoint, host]).toEqual([entrypoint, []]); + expect([entrypoint, fixtures]).toEqual([entrypoint, []]); + } + }); + + it("TG21j: the host and fixture facets are where that code actually lives", function* () { + // The complement, and the discriminator for the row above: if the split had + // simply deleted this code rather than moved it, TG21i would pass over an + // empty graph and prove nothing. + const posix = yield* graphOf("posix.ts"); + expect(posix.some((module) => module.endsWith("/posix-launcher.ts"))).toBe(true); + expect(posix.some((module) => module.endsWith("/posix-processes.ts"))).toBe(true); + expect(posix.includes("node:child_process")).toBe(true); + + // `testing.ts`, not `test.ts`: Deno's own test-file pattern matches a bare + // `test.ts`, so an entrypoint by that name would be loaded as a test file. + const fixtures = yield* graphOf("testing.ts"); + expect(fixtures.some((module) => module.endsWith("/controlled-launcher.ts"))).toBe(true); + expect(fixtures.some((module) => module.endsWith("/controlled-composite.ts"))).toBe(true); + }); + + it("TG21l: an installer's linked copies are not read as a package's own source", function* () { + // A workspace install links each dependency under its dependents, so the + // same file is reachable at `packages//node_modules/@executablemd/...`. + // Counting those would report one definition many times, and reading their + // imports would make a package appear to import whatever its dependencies + // import. Bun's layout creates the links, Deno's does not — so every row + // above was passing under one runtime for a reason that does not hold under + // the other. + for (const pkg of ["terminal", "terminal-tmux", "core", "cli"]) { + const strayed = (yield* productionSources(pkg)).filter((file) => + file.includes(`${path.sep}node_modules${path.sep}`), + ); + expect([pkg, strayed]).toEqual([pkg, []]); + } + expect( + (yield* everySource()).filter((file) => file.includes(`${path.sep}node_modules${path.sep}`)), + ).toEqual([]); + }); + + it("TG21d: a walked package with no sources would not pass vacuously", function* () { + // The rows above are absence claims, and an absence claim over an empty set + // is free. This is the discriminator: the walk finds real files. + expect((yield* productionSources("terminal")).length).toBeGreaterThan(10); + expect((yield* productionSources("terminal-tmux")).length).toBeGreaterThan(8); + expect((yield* everySource()).length).toBeGreaterThan(100); + }); +}); + +describe("Tier TG21 — the replaced paths are absent", () => { + it("TG21e: no old terminal module remains where it used to live", function* () { + // Deleted rather than emptied. A module that still resolves is a path an + // import can drift back onto, whether or not anything uses it today. + for (const gone of [ + "packages/runtime/launcher.ts", + "packages/runtime/terminal.ts", + "packages/runtime/terminal-processes.ts", + "packages/runtime/deno-terminal-processes.ts", + "packages/core/src/terminal-grid.ts", + "packages/core/src/terminal/authority.ts", + "packages/core/src/terminal/provider-api.ts", + "packages/core/src/terminal/grid.ts", + "packages/core/src/terminal/pane.ts", + "packages/core/src/terminal/pane-launcher.ts", + "packages/cli/src/terminal", + ]) { + expect([gone, yield* exists(path.resolve(gone))]).toEqual([gone, false]); + } + }); + + it("TG21f: runtime and core export none of the terminal domain", function* () { + const runtime = yield* until(import("@executablemd/runtime")); + const core = yield* until(import("@executablemd/core")); + for (const name of TERMINAL_EXPORTS) { + expect([`runtime.${name}`, name in runtime]).toEqual([`runtime.${name}`, false]); + expect([`core.${name}`, name in core]).toEqual([`core.${name}`, false]); + } + // What core does still own is the profile that composes a grid into an + // `Execution` — the adaptation, not the domain. + expect("installTerminalGridProfile" in core).toBe(true); + }); + + it("TG21g: every repository terminal import names a canonical surface", function* () { + // The complement of TG21f. An export that is gone cannot be imported, but a + // *type-only* import of a vanished name fails at typecheck rather than + // here, and this row is what says where such an import would have to move. + const offenders: string[] = []; + for (const file of yield* everySource()) { + const source = yield* readTextFile(file); + for (const match of source.matchAll( + /(?:^|\n)\s*(?:import|export)[^;]*?from\s+"(@executablemd\/(?:runtime|core))"/g, + )) { + const statement = match[0]; + for (const name of TERMINAL_EXPORTS) { + if (new RegExp(`\\b${name}\\b`).test(statement)) { + offenders.push(`${path.relative(path.resolve("packages"), file)}: ${name}`); + } + } + } + } + expect(offenders).toEqual([]); + }); + + it("TG21k: the tmux root exposes exactly its narrow provider API", function* () { + // Pinned as an exact set rather than a set of required names. `paneEnvironment` + // — a host's decision about which of *its own* variables a pane inherits — + // reached this root by being added to it, and a row that only checked for + // required names would have let it stay. + const tmux = yield* until(import("@executablemd/terminal-tmux")); + expect(Object.keys(tmux).toSorted()).toEqual( + [ + // Provider installation and factory. + "TMUX_PROVIDER", + "installTmuxGridProvider", + "tmuxGridProvider", + // Worker dispatch. + "PANE_WORKER_COMMAND", + "PaneNotQuiescent", + "paneWorkerInvocation", + "runPaneWorkerProcess", + // The refusals a reader can actually meet. + "TMUX_UNAVAILABLE", + "TerminalTeardownFailed", + "TmuxUnavailableError", + ].toSorted(), + ); + + // The low-level seams stay behind `./test`, and are really there — so the + // assertion above is a boundary rather than an empty package. + const seams = yield* until(import("@executablemd/terminal-tmux/test")); + for (const name of ["useTmuxGrid", "usePaneChannels", "usePaneChild", "tmuxAt", "runInPane"]) { + expect([name, name in seams]).toEqual([name, true]); + } + }); + + it("TG21h: each descriptor and public error constructor is defined once", function* () { + // Identity used to be provable by comparing two import paths. With one path + // left, the claim that replaces it is that there is only one definition to + // reach — so a second `createApi` or a second class cannot quietly appear + // and split middleware composition between two objects that behave alike. + const sources = yield* everySource(); + const definitions = new Map(); + // Any exported class, not just one whose name ends in `Error`: + // `TerminalTeardownFailed` is a refusal too, and a scan that keyed on the + // suffix would have reported it as having no definition at all. + const declared = /export\s+(?:const\s+(\w+)\s*(?::[^=]+)?=\s*createApi|class\s+(\w+))/g; + for (const file of sources) { + for (const match of (yield* readTextFile(file)).matchAll(declared)) { + const name = match[1] ?? match[2]; + if (name === undefined) { + continue; + } + definitions.set(name, [ + ...(definitions.get(name) ?? []), + path.relative(path.resolve("packages"), file), + ]); + } + } + + for (const name of [ + "NativeLauncher", + "TerminalGrids", + "TerminalProviders", + "TerminalProcesses", + "NativeLauncherUnavailableError", + "TerminalProviderUnavailableError", + "TerminalProcessesUnavailableError", + "TerminalProviderInstallError", + "TerminalAuthorityError", + "TmuxUnavailableError", + "TerminalTeardownFailed", + ]) { + expect([name, definitions.get(name) ?? []]).toEqual([name, [expect.any(String)]]); + } + // And the scan is not vacuous: it found the descriptors it was told to look + // for, in the package that owns them. + expect(definitions.get("NativeLauncher")?.[0]).toContain("terminal/src/native-launcher.ts"); + expect(definitions.get("TerminalProcesses")?.[0]).toContain("terminal/src/processes.ts"); + }); +}); diff --git a/packages/runtime/tests/terminal-processes.test.ts b/packages/terminal/tests/terminal-processes.test.ts similarity index 98% rename from packages/runtime/tests/terminal-processes.test.ts rename to packages/terminal/tests/terminal-processes.test.ts index 31e2ff77d..08d0c17ff 100644 --- a/packages/runtime/tests/terminal-processes.test.ts +++ b/packages/terminal/tests/terminal-processes.test.ts @@ -28,10 +28,10 @@ import { TERMINAL_PROCESSES_UNAVAILABLE, TerminalProcesses, terminalHolders, -} from "../terminal-processes.ts"; -import { installDenoTerminalProcesses } from "../deno-terminal-processes.ts"; -import type { ProcessProbes } from "../deno-terminal-processes.ts"; -import type { PaneOccupants, ProcessFacts, SignalDelivery, TerminalSignal } from "../mod.ts"; +} from "../src/processes.ts"; +import { installDenoTerminalProcesses } from "../src/posix-processes.ts"; +import type { ProcessProbes } from "../src/posix-processes.ts"; +import type { PaneOccupants, ProcessFacts, SignalDelivery, TerminalSignal } from "../processes.ts"; /** A table written by hand, so a row can describe a machine it is not on. */ function table(rows: readonly Partial[]): readonly ProcessFacts[] { diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/terminal/tests/terminal-provider.test.ts similarity index 97% rename from packages/runtime/tests/terminal-provider.test.ts rename to packages/terminal/tests/terminal-provider.test.ts index 3c88c9d83..65a54accc 100644 --- a/packages/runtime/tests/terminal-provider.test.ts +++ b/packages/terminal/tests/terminal-provider.test.ts @@ -20,13 +20,12 @@ import { scoped } from "effection"; import type { Operation } from "effection"; import { - prepareControlledComposite, TERMINAL_PROVIDER_UNAVAILABLE, TerminalGrids, - terminalProviderLog, TerminalProviderUnavailableError, -} from "../terminal.ts"; -import type { TerminalGridRequest } from "../terminal.ts"; +} from "../src/composite.ts"; +import type { TerminalGridRequest } from "../src/composite.ts"; +import { prepareControlledComposite, terminalProviderLog } from "../src/controlled-composite.ts"; /** A two-by-one grid: the smallest request that still has two ordinals. */ function request(overrides: Partial = {}): TerminalGridRequest { diff --git a/packages/test-agent/package.json b/packages/test-agent/package.json index 856665edf..f1cf70228 100644 --- a/packages/test-agent/package.json +++ b/packages/test-agent/package.json @@ -15,6 +15,7 @@ "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@executablemd/testing": "workspace:*", "acorn": "^8.16.0", "acpx": "0.12.0", diff --git a/packages/test-agent/src/child-configuration.ts b/packages/test-agent/src/child-configuration.ts index 321e56e5f..8447867f9 100644 --- a/packages/test-agent/src/child-configuration.ts +++ b/packages/test-agent/src/child-configuration.ts @@ -37,7 +37,7 @@ import type { AgentComponentsOptions, AgentProviderOptions, Json } from "@execut import { createPartitionedAcpxProvider } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; import { installInvocationAgentProvider } from "@executablemd/core/host"; -import { installControlledLauncher } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/terminal/test"; import type { ChildDeclaration, ChildDeclarationChild, diff --git a/packages/test-agent/src/components.ts b/packages/test-agent/src/components.ts index 5340d3b11..b01a2f65a 100644 --- a/packages/test-agent/src/components.ts +++ b/packages/test-agent/src/components.ts @@ -42,7 +42,8 @@ import { import type { ErrorSegment, Json, PropsSchema, Segment } from "@executablemd/core"; import { createMemorySessionRouteStore, createPartitionedAcpxProvider } from "@executablemd/acp"; import type { AcpxProvider, SessionRouteContext } from "@executablemd/acp"; -import { command, installControlledLauncher, readTextFile } from "@executablemd/runtime"; +import { command, readTextFile } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/terminal/test"; import { Test } from "@executablemd/testing"; import { NativeLaunchObserver, useTestAgentController } from "./controller.ts"; import type { ScenarioHandle, TestAgentControllerInternals } from "./controller.ts"; diff --git a/packages/test-agent/src/controller.ts b/packages/test-agent/src/controller.ts index f27327c79..157be43dc 100644 --- a/packages/test-agent/src/controller.ts +++ b/packages/test-agent/src/controller.ts @@ -20,7 +20,7 @@ import { isAbsolute, relative, resolve, sep } from "node:path"; // node:fs/promises primitive directly. import { realpath } from "node:fs/promises"; import { readTextFile, stat } from "@executablemd/runtime"; -import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/runtime"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/terminal"; import type { DurableEvent } from "@executablemd/durable-streams"; import { encodeMessage, formatRoute, parseWorkerMessage, PROBE_INSTANCE } from "./protocol.ts"; import type { ControllerMessage, WorkerMessage } from "./protocol.ts"; diff --git a/packages/test-agent/tests/native-launch.test.ts b/packages/test-agent/tests/native-launch.test.ts index 3f274d040..f21356c40 100644 --- a/packages/test-agent/tests/native-launch.test.ts +++ b/packages/test-agent/tests/native-launch.test.ts @@ -25,8 +25,9 @@ import * as os from "node:os"; import { installAgentComponents } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import type { Json } from "@executablemd/core"; -import { API, installControlledLauncher, useHostFiles } from "@executablemd/runtime"; -import type { NativeLaunchRequest } from "@executablemd/runtime"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/terminal/test"; +import type { NativeLaunchRequest } from "@executablemd/terminal"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; import { installTestAgentComponents } from "../src/components.ts"; diff --git a/packages/test-agent/tests/terminal-grid-native-launch.test.ts b/packages/test-agent/tests/terminal-grid-native-launch.test.ts index 87341cfb2..52d0c724f 100644 --- a/packages/test-agent/tests/terminal-grid-native-launch.test.ts +++ b/packages/test-agent/tests/terminal-grid-native-launch.test.ts @@ -30,25 +30,23 @@ import { agentIdentityComponents, installAgentComponents, installTerminalGridProfile, - registerTerminalProvider, useTempFileCompiler, } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import type { Json } from "@executablemd/core"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { registerTerminalProvider, TerminalGrids } from "@executablemd/terminal"; import { - API, installControlledLauncher, prepareControlledComposite, - TerminalGrids, terminalProviderLog, - useHostFiles, -} from "@executablemd/runtime"; +} from "@executablemd/terminal/test"; import type { NativeLaunchOutcome, NativeLaunchRequest, TerminalGridRequest, TerminalPaneState, -} from "@executablemd/runtime"; +} from "@executablemd/terminal"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; import { installTestAgentComponents } from "../src/components.ts"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ea97ff75..2987519c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: '@executablemd/runtime': specifier: workspace:* version: link:../runtime + '@executablemd/terminal': + specifier: workspace:* + version: link:../terminal acpx: specifier: 0.12.0 version: 0.12.0 @@ -174,6 +177,12 @@ importers: '@executablemd/runtime': specifier: workspace:* version: link:../runtime + '@executablemd/terminal': + specifier: workspace:* + version: link:../terminal + '@executablemd/terminal-tmux': + specifier: workspace:* + version: link:../terminal-tmux '@executablemd/test-agent': specifier: workspace:* version: link:../test-agent @@ -242,6 +251,9 @@ importers: '@executablemd/runtime': specifier: workspace:* version: link:../runtime + '@executablemd/terminal': + specifier: workspace:* + version: link:../terminal '@secretlint/core': specifier: 13.0.4 version: 13.0.4 @@ -315,6 +327,45 @@ importers: specifier: 4.1.0 version: 4.1.0 + packages/terminal: + dependencies: + '@effectionx/context-api': + specifier: 0.6.0 + version: 0.6.0(effection@4.1.0) + '@effectionx/fs': + specifier: 0.3.0 + version: 0.3.0(effection@4.1.0) + '@effectionx/node': + specifier: 0.2.4 + version: 0.2.4(effection@4.1.0) + '@effectionx/process': + specifier: 0.8.1 + version: 0.8.1(effection@4.1.0) + '@executablemd/durable-streams': + specifier: workspace:* + version: link:../durable-streams + effection: + specifier: 4.1.0 + version: 4.1.0 + + packages/terminal-tmux: + dependencies: + '@effectionx/fs': + specifier: 0.3.0 + version: 0.3.0(effection@4.1.0) + '@effectionx/process': + specifier: 0.8.1 + version: 0.8.1(effection@4.1.0) + '@executablemd/terminal': + specifier: workspace:* + version: link:../terminal + effection: + specifier: 4.1.0 + version: 4.1.0 + zod: + specifier: ^4.3.6 + version: 4.4.3 + packages/test-agent: dependencies: '@agentclientprotocol/sdk': @@ -341,6 +392,9 @@ importers: '@executablemd/runtime': specifier: workspace:* version: link:../runtime + '@executablemd/terminal': + specifier: workspace:* + version: link:../terminal '@executablemd/testing': specifier: workspace:* version: link:../testing diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 89105d529..5a935ee94 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -643,11 +643,17 @@ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ */ const DENO_ONLY_TERMINAL_GRID: RuntimeExclusion[] = [ { - path: "packages/cli/tests/terminal-grid-tmux.test.ts", + path: "packages/terminal-tmux/tests/terminal-grid-tmux.test.ts", reason: "the subject is the tmux provider, whose panes are this executable re-invoked as `terminal-worker` — a subcommand only the grid-presenting entrypoints register; under Node and Bun that vector names a document instead, so the worker exits with ENOENT and the pane's admission never completes", issue: DERIVED_SCOPE, }, + { + path: "packages/cli/tests/terminal-host.test.ts", + reason: + "the host rows open a real grid through the tmux provider, so they spawn the same `terminal-worker` re-invocation; on Node and Bun that vector names a document and the pane never reports, exactly as for the adapter's own suite", + issue: DERIVED_SCOPE, + }, ]; const BUN_MISSING_NODE_SQLITE: RuntimeExclusion[] = [ diff --git a/specs/decisions.md b/specs/decisions.md index e36c01658..4a4a64e2d 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -736,3 +736,73 @@ journal- and root-publication-stability snapshots in `packages/cli/tests/workflow-suspension.test.ts`, where an `API.Files` call count is explicitly not once-only evidence — document re-expansion legitimately enters that boundary before the durable effect underneath restores. + +## DEC-016: Terminal domain and tmux adapter are separate workspace packages + +**Status:** Decided + +**Date:** 2026-09-03 + +### Context + +The terminal-grid delivery proved one provider-neutral lifecycle and one tmux +implementation, but their modules remained distributed across runtime, core, +and CLI. That placement makes a second presentation provider depend on CLI +internals and makes the neutral terminal authority appear to be core-specific. +Keeping the lifecycle in core would preserve that coupling. Putting the neutral +domain and tmux in one package would remove the CLI dependency but make every +provider consumer acquire tmux-specific code and host assumptions. + +The terminal stack has not merged, so its temporary exports from runtime, core, +and CLI are not compatibility surfaces. Preserving them would leave the +ownership ambiguity this extraction removes and would add runtime as a +dependency only to keep an unreleased path alive. + +### Decision + +Terminal ownership is divided between two publishable workspace packages: + +- `@executablemd/terminal` owns the provider-neutral terminal domain: native + launch routing, terminal requests and composites, provider registration and + direct authority delivery, claims and readiness, row-major layout, the live + and durable grid lifecycle, pane routing, retained outcomes, process + observation contracts, quiescence, and controlled test surfaces. +- `@executablemd/terminal-tmux` implements that domain with tmux: capability + probing, private server and client control, explicit pane placement, + authenticated worker channels and protocol, worker child creation, display, + close-signal distinction, and ordered teardown. + +Core continues to own the authored `Terminal.Grid` and `Terminal` syntax, +source-position journal descriptions, execution-profile composition, Agent +sessions, and expansion integration. Runtime continues to own unrelated host +APIs. CLI chooses and wires the provider for each entrypoint; it does not own a +terminal provider implementation. + +The canonical descriptors, functions, types, constants, and errors move to the +new packages. Their former runtime and core exports and the old CLI terminal +implementation paths are deleted, and every repository import is updated to +the canonical package surface. No compatibility module, alias, forwarding +barrel, wrapper, subclass, or duplicate descriptor remains. + +The neutral package has no dependency on runtime, core, CLI, or the tmux +package. Core depends on terminal. The tmux package depends on terminal and +does not depend on runtime, core, or CLI. CLI depends on both packages and on +core and runtime. Runtime has no terminal dependency. Host-specific POSIX +observation is an explicit terminal adapter; +Deno and compiled entrypoints install it in the supervising host and the pane +worker, while Node and Bun continue to install neither observer nor provider. + +### Consequences + +Any terminal provider implements the public neutral contract without importing +CLI or tmux. Repository consumers use only the canonical package names. This +removal is non-breaking because none of the temporary terminal paths has +shipped. The extraction changes no authored syntax, provider name, hidden worker +invocation, durable record, private tmux protocol, diagnostic text, terminal +behavior, or provider identity. + +Both packages participate in workspace version lockstep, npm and JSR +publication, generated dependency ordering, package discovery, runtime test +discovery, and release verification. Moving tests changes the measured corpus, +so its weights are remeasured by the repository workflow rather than edited by +hand. diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 9616eb62b..a794797a3 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9280,6 +9280,52 @@ Node and Bun accept and validate the same syntax but install no provider and therefore refuse before pane start. A controlled provider that is not tmux exercises the same core contract in tests. +#### Package and host boundary + +`@executablemd/terminal` is the canonical provider-neutral package for this +contract. Its root exports native launch requests, outcomes and routing; +terminal grid and pane requests, composites and states; `TerminalGrids` and +`TerminalProviders`; provider registration; public errors; and the neutral pane +surface. `@executablemd/terminal/lifecycle` exports the direct authority, +installation, claim, readiness, row-major layout, grid lifecycle, retained +outcome, reader-close and replay operations. `@executablemd/terminal/processes` +exports `TerminalProcesses`, process facts, signals, snapshots and quiescence. +`@executablemd/terminal/posix` exports POSIX process and terminal probes and the +foreground-child adapter. `@executablemd/terminal/test` exports the controlled +launcher, composite, log and signal surfaces; production code imports none of +them. + +`@executablemd/terminal-tmux` is the first provider. Its root exports only the +provider name, dependency contract, provider factory and installer, unchanged +`PANE_WORKER_COMMAND`, hidden worker invocation parser and runner, and +documented refusal errors. Its tmux +process wrapper, layout mechanics, private protocol, channel handles and +teardown controls remain internal; its tests reach controlled low-level seams +through `@executablemd/terminal-tmux/test`. + +The neutral package imports neither runtime, core, CLI nor terminal-tmux. Core +imports terminal for the lifecycle it invokes and retains only authored +parsing, expansion, source-position journal descriptions, profile composition, +and Agent behavior. Terminal-tmux imports terminal and imports neither runtime, +core nor CLI. CLI imports the domain and provider to compose the Deno and +compiled hosts. Runtime owns no terminal module, export, or dependency. + +The previous `@executablemd/runtime` and `@executablemd/core` terminal exports +and the old CLI terminal implementation paths are deleted. They have not +shipped and are not compatibility surfaces. Every repository import names +`@executablemd/terminal`, one of its documented subpaths, or +`@executablemd/terminal-tmux`; no alias or forwarding barrel keeps an old path +reachable. Each contextual API and public error constructor consequently has +one canonical definition. + +The Deno and compiled CLI entrypoints select tmux, supply self-reinvocation, +environment and terminal dimensions, translate `SIGHUP`, and install POSIX +observation in the supervising host. The hidden pane-worker entrypoint installs +the same observation inside its own process; contextual installation in the +parent cannot cross that boundary. Node and Bun install neither the process +observer nor a grid provider. The extraction changes no syntax, provider name, +worker invocation, protocol, durable value, diagnostic, or lifecycle outcome. + ## 7. Entry point @@ -11294,6 +11340,7 @@ test derives a core result from a provider identifier. | TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17 and TG19; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | | TG19 | Reader close crossed with parent cancellation | A controlled live pane enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets pane and provider teardown complete, retains the pane as `closed` and the grid with its reader-close result, and only then delivers cancellation to the parent. A continuation neither contacts the provider nor enters pane work, does not hang, and proceeds from the retained grid outcome. Provider-resource and following-sibling observations prove both sides of the ordering; no elapsed duration is evidence | | TG20 | Pane-native physical endpoint | A paired pane's native launch passes through nearer launcher middleware and then the required composite operation for its authored ordinal. Production tmux evidence observes the exact argv, cwd, and environment at that pane's authenticated worker while a root-foreground-launcher sentinel is never entered. Distinct pane workers accept concurrent launches. Cancellation settles only after worker-reported child settlement and pane-terminal quiescence. A root launch still enters the root foreground launcher unchanged, and a composite unable to execute a pane launch refuses without fallback | +| TG21 | Package boundary and canonical imports | Static dependency evidence proves terminal imports neither runtime, core, CLI nor terminal-tmux; terminal-tmux imports terminal and none of runtime, core or CLI; runtime has no terminal dependency; and CLI alone composes the document engine with the provider and host. The old runtime, core and CLI terminal modules and exports are absent, every repository terminal import names a canonical package surface, and each contextual descriptor and public error constructor has one definition. The relocated neutral, tmux, cross-package Agent and Deno/compiled host suites retain TG1–TG20 without changing syntax, provider identity, hidden-worker grammar, protocol, durable records or diagnostics; Node and Bun still install neither observer nor provider | ### Tier CR — Component registration and resolution diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 2de01c5e1..46c04eb97 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -1192,6 +1192,31 @@ remain role and continuity identities. V1 defines no stateful-Agent model selection. A document can explicitly name an Agent where required, but no provider-specific executable or resume syntax appears in `AGENTS.md`. +### Terminal package boundary + +`NativeLauncher`, `NativeLaunchRequest`, `NativeLaunchOutcome`, terminal +reservation and output flushing are canonically exported by +`@executablemd/terminal`. The same package owns the pane claim and the +provider-neutral composite endpoint that receives a native launch. The Agent +request, construction route, session coordinator and `Session.Launch` +component stay in their existing Agent and core modules; neither acquires a +terminal-provider identity. + +`@executablemd/terminal-tmux` consumes that endpoint and supplies the physical +pane worker. It does not import core, runtime or CLI. The Deno and compiled CLI +hosts compose the two domains and provide self-reinvocation and POSIX process +observation; Node and Bun continue to compose neither a foreground grid +provider nor an observer. + +The former `@executablemd/runtime` native-launch exports, +`@executablemd/core` pane and terminal-provider exports, and old CLI terminal +implementation paths are deleted. They are unshipped and carry no compatibility +contract. Every repository consumer imports the canonical terminal packages, +and each contextual descriptor and public error constructor has one definition. +This extraction changes no launch request, phase, route, ownership key, durable +record, result, diagnostic, provider advertisement, or root-versus-pane +behavior. + ## Testing The test-agent stack supplies deterministic provider state. A controlled native @@ -1477,6 +1502,13 @@ Implementation review checks these frozen invariants: pane's authenticated worker, the root foreground launcher is not entered, distinct panes launch concurrently, cancellation awaits worker settlement and pane quiescence, and root launch routing remains unchanged. +30. Native-launch and terminal-provider descriptors and error constructors have + one canonical definition under the terminal packages; the former runtime, + core and CLI terminal paths are absent and every repository import is + canonical. The terminal package imports no Agent, core, runtime, CLI or tmux + module, the tmux package imports only the neutral terminal domain, and the + complete launch evidence above passes without changing any request, route, + record, provider advertisement or diagnostic. Item 12 is the 2026-08-20 architecture amendment. ACPX fixes `systemPrompt` at session creation, while native turns are not authoritative in its cached diff --git a/specs/release-process-spec.md b/specs/release-process-spec.md index 48e8a58ec..dbef248e9 100644 --- a/specs/release-process-spec.md +++ b/specs/release-process-spec.md @@ -49,10 +49,11 @@ sequenceDiagram ## 2. Version lockstep Every publishable package (`packages/core`, `packages/cli`, -`packages/durable-streams`, `packages/runtime`, `packages/testing`, -`packages/code-review-agent`, `packages/test-agent`, `packages/acp`, -`packages/web`, `packages/workflow`) declares the same version in its `deno.json` and -`package.json`. A member marked `"private": true` is outside the lockstep +`packages/durable-streams`, `packages/runtime`, `packages/terminal`, +`packages/terminal-tmux`, `packages/testing`, `packages/code-review-agent`, +`packages/test-agent`, `packages/acp`, `packages/web`, `packages/workflow`) +declares the same version in its `deno.json` and `package.json`. A member marked +`"private": true` is outside the lockstep because it never publishes — `packages/test-support` is the one, and it stays at `0.0.0`. `packages/cli/src/cli.ts` imports `packages/cli/deno.json` and reads `version` @@ -77,6 +78,31 @@ the checked-out revision with `deno task setup` and `deno task build`, then run install the latest published release, so a review always understands the documents at the revision it checks. +### Terminal package order + +The terminal packages follow the same manifest-derived publication graph as +every other workspace member. `@executablemd/terminal` depends on +`@executablemd/durable-streams` and the external Effection packages, not on +runtime, core, CLI, or terminal-tmux. `@executablemd/terminal-tmux` depends on +terminal. Runtime has no terminal dependency. Core depends on terminal as well +as its existing runtime and durable-stream dependencies. CLI depends on +terminal-tmux, terminal, core, and runtime. + +The generated npm jobs consequently publish durable-streams before terminal; +terminal before terminal-tmux and core; and terminal-tmux, terminal, core and +runtime before CLI. Runtime remains an independent leaf. The +workspace package names and versions are also recorded in `bun.lock`. Adding +the two manifests or changing these sibling dependencies requires +`deno install --frozen=false`, the repository's normal setup, and +`deno task gen:publish-workflow`; `publish-packages.yml` remains generated and +is never edited by hand. + +Moving terminal tests between workspace members changes test-corpus paths. The +runtime exclusions continue to name every deliberately excluded file, and +`test-weights.json` is remeasured by the Measure test weights workflow on the +exact implementation head. No timing value is copied, renamed, or edited by +hand. + ## 3. Workflows - **`draft-release.yml`** (`push: main`): maintains the rolling draft release @@ -186,7 +212,7 @@ already carries at that version, member by member. A rerun after a partial publi therefore completes exactly the members that are missing, and a rerun after a complete publish exits 0 without republishing. Never gate the job on one package's existence — whether `core` is published says nothing about the other -six. +packages. `deno task check:jsr` runs the same command with `--dry-run` and is a required CI job on every PR (§3, `ci.yml`). It enforces JSR's fast-check rules, so every