🧪 Verify the complete terminal-grid stack on main (#717) - #772
Conversation
`<Terminal.Grid>` and `<Terminal>` become reserved core structural syntax. This Story owns the authored structure alone: the grammar, the placement rules, and the row-major layout a grid derives. No terminal authority, provider, pane execution, shell, durability or replay is built here. The grid's closed props hold one required positive-integer `columns`; a pane's hold one required non-empty `title`. Titles are display labels and identify nothing — a pane's structural identity is its ordinal among the grid's direct children, and rows are derived in authored row-major order with the last row left short. `structural-rules.ts` decides what the source says, so expansion and document validation cannot disagree about it: only whitespace and direct `<Terminal>` panes may appear in a grid, and an empty grid, direct text, a non-pane element, a control structure that would produce panes, a nested grid, and a pane written anywhere else are refused. `terminal-grid.ts` places the panes once `columns` and each `title` are known. A grid the grammar accepts runs until a terminal provider would be asked for one. This build installs none, so it refuses there, before any pane body expands or a default shell starts, and carries the layout it derived beside the refusal. Evidence rows TG1-TG4: the new structural suite owns TG1, TG2 and TG4; the catalog, validation and `xmd syntax` suites own TG3.
Both descriptions lead with what the author can do and show the invocation to copy. The placement rule, the row-major detail and the title's display role are the catalog's structured fields and §6.21's to state, not a second reference entry beside the forms. The frozen catalog entries in `syntax-catalog.test.ts` mirror the two declarations, so they move with them.
The replaceable seam a terminal grid executes through, before any of the execution that uses it. `packages/runtime/terminal.ts` is the contextual provider: `prepare()` builds the whole composite while it stays hidden, `attach()` shows it once every pane is ready, and `destroy()` gives the root terminal back. The request is provider-neutral — columns, rows, and the authored panes with their derived positions — and names no terminal, socket, process or window. Middleware may observe, narrow, refuse, wrap or delegate; presentation never decides an outcome, so `update()` receives states core has already settled on. `packages/core/src/terminal/authority.ts` mints one-use pane claims for one request's ordinals. A claim admits one interactive operation at a time on its pane and holds that pane's readiness latch. Two claims do not contend, which is what lets panes stay interactive together. `packages/core/src/terminal/pane.ts` is the seam interactive work inside a pane reaches for, so it runs as that pane's owner instead of competing for the root foreground lease. Absence means "not in a pane". Evidence: `packages/runtime/tests/terminal-provider.test.ts`, 11 rows.
`runTerminalGrid()` owns the lifecycle the reader sees: it takes the run's one foreground-terminal lease, flushes root output, prepares the composite while it stays hidden, starts every pane concurrently, and attaches only once every pane has reported a spawn through its claim. Ordering is the contract. The lease and the composite are both scope-owned, so success, failure and cancellation all release the terminal and destroy exactly the composite that was prepared — there is no path that skips teardown. A pane that settles without ever reporting a spawn fails startup rather than being presented as a running pane. Before the barrier a pane failure fails the whole grid closed; after it, the failure is that pane's status and its siblings keep running. Close cancels a live pane as `closed`, which is not a failed pane, and the grid fails with the first failed pane in authored order. `display()` and an `onUpdate` hook complete the provider surface: a pane's rendered text goes to that pane, and a suite reacts to a state the grid decided rather than waiting and hoping. Evidence: `packages/core/tests/terminal-grid.test.ts` (15 rows) and `packages/runtime/tests/terminal-provider.test.ts` (11 rows). The readiness barrier row was verified by removing the barrier: it fails without it.
`<Terminal.Grid>` now executes. Each authored pane becomes a concurrent child of the grid: a self-closing pane runs the host's default shell through its claim, and a paired pane expands its own content in a scope of its own. A pane inherits the bindings, providers, configuration and working directory visible where the grid was written, and keeps everything it creates afterwards. Its `<Break>` has no loop to exit, its `<Return>` has no enclosing value body to claim, and its checked failures settle the pane rather than reaching the root or a sibling. A pane's rendered text is displayed in that pane; the grid itself renders `""`, so the root output holds what surrounds the grid and no pane display at all. #729's five execution-dependent rows move here, where they assert the layout against the request the provider actually receives rather than reading it off a refusal's cause — the structural suite keeps the grammar, placement and pure-layout rows it owns. Moving them was authorized rather than assumed. Evidence: 22 rows in `packages/core/tests/terminal-grid.test.ts` and 11 in `packages/runtime/tests/terminal-provider.test.ts`; the whole Deno core (350) and runtime (14) suites pass.
`paneWork()` never read the grid element it was handed. Its caller has it, and a pane's own diagnostics are positioned at the pane.
…#730) Restores the authority boundary on the `AgentProviders` handshake, and puts each pane on its own durable child coroutine. **The boundary.** `TerminalGrids` is routing and only routing: `open()` answers `unknown` and core throws the answer away, so middleware may observe, narrow, refuse, wrap or delegate but can never authorize. The capability that takes the leases, mints pane claims and settles a grid is a non-contextual authority delivered straight to the registered provider through a one-use install handshake. Core mints one identity-bearing request per expansion; presenting a copy, a rebuilt lookalike, a changed request, an already-presented one, or one from a superseded installation generation authorizes nothing, and a handler that answers without presenting settles nothing. **Durable children.** Each pane is a durable child of the grid, allocated in authored order, so a pane's identity follows its ordinal rather than the order the runtime scheduled it in. The layout is recorded in the parent coroutine before the lease and before any provider is contacted. **Ordering.** A pane that settles before attach keeps the status it settled to instead of being overwritten with `running`, and simultaneous startup failures are selected by authored ordinal rather than by whichever rejected first. Each pane also expands under a counter of its own, so two concurrent panes cannot take block identities that depend on which ran first. `durableSpawn` could not be used: the task it returns is spawned inside the ephemeral effect's own scope, which closes as the effect resolves, so awaiting it throws `halted`. It has no call sites or tests upstream. `durableAll` is the exercised primitive and is what the panes and the grid child use. Evidence: 30 rows in `packages/core/tests/terminal-grid.test.ts` and 10 in `packages/runtime/tests/terminal-provider.test.ts`; core 349, runtime 15.
`durableSpawn` returned a task spawned inside the `ephemeral` effect's own scope, and that scope closed as the effect resolved — so every `yield* task` threw `halted`. It had no call sites and no tests. It now starts the child in the routine's own scope, so the task outlives the call and can be awaited or halted by whoever asked for it. A retained `Close(cancelled)` meant one thing to the code and two things in practice. Under `durableRace` and `durableAll` it is a race loser or a fail-fast sibling, and the same combinator cancels it again — those keep DEC-024 exactly. Under `durableSpawn` nobody cancels it a second time, so suspending hung the resumed run forever. `runDurableChild` now takes an explicit `CancelledChildPolicy`, fixed at each combinator's call site and never chosen by a caller. Resuming uses a new internal `ReplayIndex.reopen()`, which forgets one coroutine's retained Close while keeping its yields — so the child continues its own history rather than restarting, and the divergence guard stops reading the remaining effects as a coroutine continuing past its own close. Neither it nor `disableReplay` is exported. DEC-039 records the policy and marks DEC-024's invariant as superseded in part: it assumed every cancelled child belongs to race or all. The grid uses the repaired primitive: the whole grid is one durable child, each pane is its own durable child allocated in authored ordinal order, and each pane task is observed outside its child — so a replayed pane's retained outcome publishes its status and satisfies the readiness barrier without entering a body, a shell, or a launcher. Evidence: 9 rows in `packages/durable-streams/tests/durable-spawn.test.ts` (lifetime, completed replay, interrupted resume, retained-history continuation, and both combinators keeping their own policy); 30 rows in `packages/core/tests/terminal-grid.test.ts`. durable-streams 32, core 349, runtime 15.
Two decisions exposed while implementing #730, and no implementation. **DEC-040 — a cancelled child records why.** DEC-039's `"resume"` fired on every retained `Close(cancelled)` under an incomplete parent, which revives work a caller deliberately halted: the record of a deliberate `task.halt()` and the record of an interrupted run are the same event. The cancelled close now carries `cancellation: "caller"` or `"unwound"`, written by whichever path cancelled the child, and `"resume"` continues only `"unwound"`. A deliberate stop suspends, which is DEC-024's reproduction argument applied to a caller instead of a combinator; a legacy record with no reason reads as `"caller"`, because refusing to revive is the safe direction. The reason is retained evidence, not authority: nothing outside `runDurableChild` reads it, and no caller chooses a policy. Terminal grids need nothing wider. A grid halts its pane tasks at close, so those retain `"caller"` — and the grid child completes, so a resumed run short-circuits the region and never reaches them. The case that must resume, an interrupted run, unwinds and retains `"unwound"`. **TG17 narrows to the resolved layout.** A continuation executes the root the journal retained; the supplied source is not read, compared or refused (proved in #722). A grid's authored structure — pane count, order, form — is therefore fixed for the life of a journal and cannot differ between runs, so comparing it compares a value with itself, which is why the refusal never fired. What a fixed retained document still resolves differently is `columns` and each `title`, through prop-borne values, since props are not restored. Those refuse before the lease and before provider contact. Authored-structure change is a root-definition compatibility question, not a grid one. Root-definition authority is preserved rather than overridden by a pre-replay comparison against the current file, and the versioned root boundary that would refuse a changed source stays open work.
**DEC-040.** A cancelled Close now records why: `cancellation: "caller"`
when the owner halts the task `durableSpawn` returned, `"unwound"` for
anything involuntary. `durableSpawn` resumes only `"unwound"`; a deliberate
stop suspends until the caller's deterministic control flow halts it again,
and a record with no reason reads as `"caller"` so nothing legacy is
revived. `durableAll` and `durableRace` keep DEC-024 whatever the reason
says.
The halt is intercepted without changing the public `Task` surface: the
returned task carries every member the real one defines, copied with its
prototype, and only `halt` is replaced. A proxy cannot do this — a task's
members are read-only and non-configurable, so a `get` trap is required to
hand back exactly what the target holds.
The reason had to survive three boundaries that were dropping it: the
protocol parser, the observable copy, and — the one that actually mattered —
`detachResult`, which froze every cancellation down to `{ status }`.
**TG15.** The harness's `attached` and `pastGrid` signals are now separate,
and a run that expects its grid to complete waits for the sibling *after*
the grid before halting the root at `<Hold />`. That is what leaves a
completed grid child under an incomplete root, which is the only state in
which a completed region can be observed replaying at all. Both a successful
grid and a contained failed one replay their exact retained result with no
provider, pane content, shell or launcher work, and each row asserts the
grid child genuinely recorded a terminal close. No timeouts.
**TG17.** Prop-borne `columns` and `title` change independently against one
fixed retained document — the only things a fixed retained root can still
resolve differently — and each refuses with zero provider observation. For
supplied-file changes to pane count, order and form, the continuation opens
the retained structure rather than the file's, asserted request-for-request.
The retained record carries every authored pane's ordinal, title, form and
derived position.
`readLayout()` parses totally: the layout object and every pane field, with
missing, extra, mistyped, out-of-position and self-inconsistent records all
refused rather than half-read.
Evidence: durable-spawn 14 rows, terminal-grid 36 rows, structural 13,
provider 10. Packages: durable-streams 33, core 349, runtime 15,
workflow 172.
#730) **The harness cannot pass a hung replay any more.** `runInterrupted()` had a 1500ms timer racing its signals, so a replay that hung returned a DocumentRun that looked finished; it also slept a fixed 5ms to let records land. Both are gone. It now waits only on events the run produced: `attached`, `pastGrid`, and a new `panesSettled` for the rows that read pane records — a pane's status is published only after its durable child returned, so counting settled panes is also counting durable pane closes. A replay that hangs now reaches none of them and hangs the row. **TG15's failed case is a real contained failure.** A pane that fails before attachment fails the whole region, so the old document could not both fail and continue. The failing pane is now a shell that starts, waits for attachment, and only then exits badly — contained as that pane's status, with the grid settling as failed and the document carrying on. Both runs capture the printed errors, and the row asserts the replayed run produced the same ones, reached `PAST_THE_GRID`, and did no provider, pane, shell or launcher work. **DEC-040 gets boundary tests where the evidence actually travels.** `parse.test.ts` round-trips both reasons to the same bytes, keeps a legacy absence absent, and refuses an unrecognised reason at `$.result.cancellation`. `retained.test.ts` proves retention and `consumable()` carry both reasons, leave a legacy absence absent, drop an unrecognised one to the safe default, and that the reason reaches the replay index. The DEC-040 rows in `durable-spawn.test.ts` no longer coordinate by delay: a child says when it is running, and the caller says when it has halted. **Malformed retained layouts** are covered by replaying a real journal with only its layout entry replaced — a missing member, an extra one, a mistyped one, a pane out of position, and a record that disagrees with itself. Each refuses with zero provider observation. The `durableSpawn` doc comment no longer says every retained cancellation is an interrupted run.
The DEC-040 block still slept where it meant to synchronise — my previous replacements silently failed to match after the file was reformatted, so none of them landed. The block is rewritten rather than patched. Every row now waits on something the run reported. A shared `living()` child resolves a `started` signal and then suspends, so each row halts or unwinds a child that is provably live rather than one a delay happened to reach. The caller resolves `halted` after performing its deliberate halt, so a run is interrupted only once both facts — the deliberate stop and the interruption — are in the journal. Non-revival is established by control flow rather than by waiting: the resumed run reaches its own `task.halt()` and says so, and a revived child would have recorded its mark before the caller could get there. The legacy-absence row signals once the child has been asked for and the request returned. No new timeout, and `sleep` stays imported because the lifetime rows above still use it deliberately. `retained.test.ts` drops the cast and the row it supported: rejecting an unrecognised reason is the parser's, proved there, and retention proves only that `"caller"`, `"unwound"` and a legacy absence survive.
…730) **`Symbol.asyncDispose` bypassed the deliberate-stop evidence.** The task `durableSpawn` returns copied it from the original unchanged, so `await using` — or an explicit `task[Symbol.asyncDispose]()` — recorded `cancellation: "unwound"` and the next run revived the child. `halt()` and the async dispose are the same decision spelled two ways, and both are now observed. Awaiting a task is not a stop and is left exactly as it was. A regression disposes a live task, asserts the retained reason is `"caller"`, resumes the journal, and proves the body is not entered again. **Reader close no longer halts panes.** It asks them to stop: a pane races its work against a close signal, settles as `closed`, and records that outcome as its own. Nothing on the ordinary close path is a caller-cancelled child any more, so a resumed run restores a pane the reader closed rather than finding a cancelled child it must either re-enter or wait on forever. Statuses are published before anything is awaited, so a pane with slow finalizers cannot delay the outcome the grid already knows. **§6.21 now agrees with architecture.md and TG17.** Partial replay compares the resolved layout — columns and titles. Pane count, order and form come from the retained root and cannot diverge within a continuation, so a changed supplied file is ignored in favour of the retained structure; refusing a changed authored structure is a root-definition boundary this specification does not yet define. DEC-040 is unchanged and nothing deliberately stopped is revived.
Implements the amendment at 1833870 without revising it. **The live handshake.** `composite.closed()` settling now only *proposes* the boundary. The grid's durable child publishes that proposal and waits; the owner awaiting the child acknowledges it; and only then does the grid seal admission and ask its panes to close. The handshake is one live rendezvous — no provider identity, nothing journaled. **Committing an outcome before the scope finishes unwinding.** A durable child can now declare its terminal value, and `runDurableChild` records that value if the child never reaches a normal ending. That is the piece the contract needs: the grid commits its retained record as the boundary is crossed, and each pane live at that moment commits `closed`, so a cancellation arriving while pane and provider finalizers are still running records what close decided rather than a cancellation. Committing is live state; it reaches the journal only as the ordinary `Close`. A child that returns or throws normally overrides it, and a child that never committed still records the cancellation it actually reached — DEC-040 untouched. Cancellation stays deferred because Effection completes a child's teardown — pane finalizers, provider destroy, terminal restoration, lease release, the `Close` append and the task's settlement — before the halt reaches the owner. **Pane work stays inside its ordinal-derived durable child.** Reader close asks the pane to close; it never halts the pane's durable task. The pane commits `closed`, stops its live nested work through its own scope, and settles only once that work and its finalizers have settled. No durable closing marker was added, and completed replay is unchanged.
Implements architecture 1833870 at the owner boundary. The grid's durable child now runs in a scope of its own — a child of the owner's, so it inherits every context the document runs under, and its own so that tearing the owner down does not reach it first. A finalizer registered after that scope exists runs before it is destroyed, and that is the cancellation-deferred await: once the owner has acknowledged the provider's close proposal, the grid and its panes finish teardown and append their ordinary completed Close records, and only then does the cancellation carry on to the parent. Removes the exported CommitOutcome/durableSpawn(commit) API. Cancellation is never turned into success inside runDurableChild; durableSpawnIn only says where a child lives, and grants nothing a caller does not already have. TG19 proves the ordering with signals alone: a live pane arms a blocking finalizer, the reader leaves, the finalizer is entered and held, cancellation begins, the finalizer is released, and the run ends with the composite destroyed, a completed grid Close retained, the live pane retained as closed — and the sibling after the grid never reached. The continuation then replays past it with no provider, no pane body and no finalizer re-entered. TG6 isolates paired-pane sequencing on its own: the reader leaves only once the pane's second component has run.
The production lifecycle is unchanged. TG19 now reads counters and journal records rather than a log's shape. The controlled composite keeps live resource counters — composites prepared, composites attached, shells started — each raised when it takes something and lowered when it gives it back, however it left. TG19 reads them once while a pane finalizer is blocked, so it knows they went up, and again when the cancellation has completed, so it knows they came back down. The harness now says when a blocked finalizer *leaves*, not only when it is entered: a finalizer that was entered and then cancelled reaches the first hook and never the second. And after every interrupted run it takes the foreground lease and gives it back twice — the first proves the grid returned it, the second proves the harness did. TG19 adds: one grid Close(ok) retaining close: "reader"; two pane Closes, both completed, with no cancellation recorded at either level; the finalizer entered and left exactly once; destroy:0 exactly once. The first-attempt claim that no following sibling ran and the replay tripwires are unchanged. Every one of these was broken on purpose and re-run: dropping any of the three counter releases, the deferral, the finalizer-exit hook, or double-logging destroy fails TG19, and a second holder of the foreground lease is refused.
A `<Session.Launch>` written at the root takes the run's one foreground terminal, so native UIs are sequential. Inside a `<Terminal>` that would defeat the point of a grid, where every pane is interactive at the same time. So core installs a native launcher in each paired pane's scope, closed over that pane's claim. `<Session.Launch>` finds it by being written there: it is handed no pane, ordinal, token or mode, and its request, result and retained phases are the ones a root launch would have. What changes is which terminal answers `reserve` and `flush` — the pane's, through its claim, so two panes do not contend and one pane admits one live launch at a time. A pane also flushes what it has rendered before the UI draws over it, which is the root rule in the one place a pane's text goes. Readiness now has a boundary a launch can report. `NativeLauncherHandler.launch` takes the runtime's child-start event as a parameter — not a request member, not a context, not a result — and the foreground launcher reports it from the child's own `spawn` event, before it waits for the exit. The pane launcher listens and trips its claim's latch there and nowhere else: preparation, the reservation, the flush and an allocated PID are not a start, and a child that never ran never reports one. `nativeLaunch()` is unchanged for adapters, which hear nothing about the start. Terminal ownership and Agent-session ownership stay independent. Nothing pane- derived enters the coordinator key, the launch request, the retained record or a diagnostic, and two panes naming one logical session still contend through the existing non-waiting coordinator. No tmux, no new Agent advertisement, and root launch behavior is unchanged. Evidence: SP1–SP5 in the core launch suite (pane lease, concurrency, readiness, a failure before the start, one-live-launch-per-pane), FL8–FL9 in the runtime launcher (the start event, and a child that never starts), and Tier GN over the checked-in journey `TerminalGridNativeLaunch.test.md` through the whole TestAgent stack. Removing the pane launcher fails SP1–SP4 and GN1–GN4; never reporting readiness fails SP1, SP2, SP3 and SP5.
No production change. The pane-scoped launcher, the runtime spawn callback, the authority boundaries and root-launch behavior are exactly as reviewed. The checked-in journey is now the 2×2 grid TG5 asks for: three native Agent sessions and the host's default shell. All four children report their start before the composite attaches, each waits for its siblings while holding its own pane, and the row reads back the authored row-major positions and forms. Four rows added, all driven by signals this run produced: - GN7: after attachment one native UI exits nonzero while its sibling is live. Only that pane fails; the sibling is observed alive on the far side of the failure and stops only when the reader leaves; the grid ends on the pane that failed, and the close's cancellation is not a second failure. - GN8: the reader leaves with both launches live. Both are cancelled where they stood, neither pane fails, the composite comes down — and a root launch after the grid, naming a session a pane held, proves both leases came back. Which refusal it gets is the proof: not "already holds this run's terminal", not "another owner is using session", but the #517 recovery tombstone a cancelled native UI leaves behind. - GN9: a pane admits its next user only once the last one is wholly done, with the launch and the prompt that follows it going through the real coordinator. - GN10: a grid interrupted with a live pane launch, resumed on the same journal. It rebuilds the composite, starts the native child on the identity the first attempt retained, prepares nothing, and the retained record comes back unchanged — identity, route, binding and phase alike. SP5 now proves the pane stays held through both halves: refused while the child is live, refused again once the child has gone but the lease around it is still unwinding, admitted only after both. A launch that merely returned showed only the first. Two harness repairs. Pane states are read as a set of panes rather than a count of messages — a pane still live when the reader leaves is told twice, once from the outcome close decided and once from its own settlement, and that is display rather than a second settlement. And a generated variant is written to a directory of its own with copies of the scenarios it names, so a killed run leaves nothing in the repository.
The harness's interrupted branch built a `Result` as an object literal and cast it. Effection has a constructor for exactly that, so it uses it: no cast, and the type is the constructor's rather than an assertion's. Behavior and evidence are unchanged.
An orderly cancellation that finished proves everything a normal return proves, and must say so. It did not: `ownership.quiesced()` was a statement after `authority.perform()`, and cancellation unwinds past every statement after the operation it cancels. A reader closing a terminal grid therefore left every session its panes had launched carrying a recovery tombstone, and the next owner was told to recover a session nothing was using. The launch now runs in a scope the ownership body owns, and the acknowledgement is that scope's cleanup — reached on every path there is, cancellation included. It brings the launch down deliberately and reads the outcome of doing so, so the two facts it needs are facts rather than inferences: the native child and its cleanup settled, and this provider holds no handle for the session. A teardown that could not prove the child stopped throws out of `destroy()` and is not quiescence — and is still a failure, so it propagates rather than passing quietly. Nothing grid-specific reaches the provider. Reader close is the ordinary launch cancellation path, and this is the ordinary launch cancellation path's rule. The conservative cases keep their tombstone: a detach that failed or a session prepared and never handed over leaves a handle, and a child or provider cleanup that failed leaves the acknowledgement unmade. Cancellation, a released lease, a PID and elapsed time still prove nothing on their own. CX1 asserted the behavior this replaces — that a cancelled launch stays owned — so it now asserts the accepted one. CX2 is new and holds the other half: a cleanup that could not finish withholds quiescence, and the record stays active. GN8 is rebuilt as directed: two pane children held on unresolved operations, signals from each child's own teardown, teardown proven to finish after both, and a root launch afterwards on one of the same logical sessions that acquires ownership and starts — receiving neither session-busy nor session-recovery-required, and reclaiming the root foreground lease as it goes. Broken on purpose and re-run: acknowledging only on a normal return fails CX1 and GN8; acknowledging without proving the cleanup settled fails CX2, and only CX2.
A terminal grid may not report a pane settled, admit the next launch into it, or let the document continue while something a launch started can still act. A PID, a delivered signal, an attach client going away and an elapsed timeout each establish none of that. `packages/runtime/terminal-processes.ts` is what does: the process table, terminal holders, signal delivery and reachability, behind one host seam whose own default refuses every question. Refusing is the point — "nobody is there" and "I cannot see" are the two answers a quiescence proof must never confuse, so a host that installs no observer stops the document rather than reporting a pane quiet it never looked at. The POSIX handler answers with `ps` and `lsof`; the `lsof` sweep is the expensive half and grows with the process count, which is why it is behind the seam rather than inlined. Two shapes carry the rule. `paneOccupants()` takes the snapshot — the child, its descendants, its process group — and must be taken *before* the first signal, because a killed child's children reparent to init and a later reading names fewer processes than the launch actually started. `establishQuiescence()` asks about every one of them and about the terminal, and reports everything still true rather than the first thing it found. Nothing here decides policy. It reports; the pane worker finishing a launch and the provider tearing a grid down decide what the report means. Tier TP proves the difference between establishing and assuming: a host with no observer refuses, the POSIX reader finds this process in the real table, a snapshot read after a kill names nobody, and a pane whose child is gone is still not free while a descendant runs or anything else holds the terminal.
`select-layout tiled` picks its own column count from the window's dimensions,
so the same four panes are 2×2 in one terminal and 4×1 in another. An authored
`columns` has to be told to tmux rather than asked of it.
`packages/cli/src/terminal/layout.ts` writes the description tmux prints in
`#{window_layout}` and accepts back: a checksum, then a tree of cells sized
row-major from the pane count and the authored column count. A final row with
fewer panes than columns spans the row, because tmux has no empty cells and the
author wrote panes rather than a rectangle.
One thing the string cannot do is place a particular pane — tmux fills the
leaves in window-list order and ignores the pane ids they name — so authored
order is imposed afterwards by swaps. `swapsInto()` says which, produces none
for an order that is already right, and refuses a window that does not hold a
pane the author wrote instead of putting some other pane there.
Tier TX checks the geometry at four terminal sizes, that the cells tile exactly
with one separator between them, that the checksum tracks the tree, and all
three swap cases.
A pane's initial process is a worker that owns the pane's terminal for the pane's whole life, and everything it does is asked of it over a socket only this invocation can reach. **The channel.** One directory per grid, mode 0700, directly under `$TMPDIR` because a Unix socket path is capped at 104 bytes and a directory named after a repository path spends most of that first. Inside it, one socket and one mode-0600 token per pane, both written before any pane exists, so a worker that starts finds its socket listening rather than racing it. Admission is the whole boundary: a connection is admitted when its first frame is a `hello` naming this pane's ordinal and carrying this pane's token, and a connection that says anything else, says it late, names another ordinal, or arrives after that pane is admitted is closed without being answered. The token is single-use because the worker removes the file as it reads it. **What crosses it.** The exact argv vector, working directory and environment. tmux has a command parser, and a command parser is a place where an argument can become two arguments, or a quote, or a `;`. tmux is told a directory and an ordinal, and that is all its parser ever sees. **The worker.** `xmd terminal-worker <ordinal> <dir>` — reusing this executable rather than shipping a second script, which is what makes it work in the compiled distribution. It is in no command table, so it is in no help output and no catalog, and naming it grants nothing: without a pane's single-use token nobody answers. It is dispatched at the entrypoint, before `main()`, and runs under `run()`, because `main()` binds SIGINT to its own shutdown and would exit 130 on the first `^C` typed into the pane — the keystroke the foreground child is supposed to receive. It ignores SIGINT, SIGQUIT and SIGTSTP itself so the child, which gets default dispositions across `exec`, is the one interrupted. **Readiness and settlement, kept apart.** Readiness is the runtime's `spawn` event and nothing earlier; a missing executable delivers `error` instead of it, never after it. Settlement is the escalation and sweep that follow — a child that exited on its own may have left descendants in its group or an orphan holding the terminal, and the pane is not free until neither is true. `exited` is reported only after that, so the next launch is refused while a sweep that would reach it is still running. One hazard the evidence found: the settlement sweeps the process group it is in, and a worker that was not a session leader would be sweeping whatever started it. In a pane tmux makes it one — but a settlement one signal away from killing the run that started the grid is not something to leave to the topology being what it should be, so the sweep now never reaches an ancestor of the worker. Tier TW proves it with a real worker process over a real socket and no tmux at all: the modes, the removal, the handshake, three ways of failing it, awkward argv crossing intact, a child that never starts, one-live-child exclusivity, display written and never read, and shutdown's final sweep.
One invocation-private server per grid, on its own socket, started with `-f /dev/null` so a reader's `.tmux.conf` cannot redecide an authored layout. A pane per authored ordinal, each running that pane's worker — tmux's parser sees an ordinal and a directory and never a launch's argv. Nothing is visible until `attach()`, which core calls only after every pane has reported a start. Three clients, kept apart because they answer different questions. The visible one is the reader's. The control one attaches `-f no-output`, so pane bytes never travel through this process, and what it reports is how reader detach, server stop and control loss are told apart — an attach client's exit code cannot tell them apart, being 0 after `detach-client`, 0 after `kill-session` and 1 after `kill-server`. The workers are not clients at all; they are the panes. Teardown is registered before the first command, so a composite that fails half-built still takes its server down. A detach is *asked for* before anything is signalled, because a client that leaves restores the terminal and one that is killed cannot. `stop()` establishes the server pid is unreachable and the server refuses its session — never the socket file's absence, which outlives it. `probeTmux()` answers the prerequisites before a server exists: a terminal to divide, and a tmux new enough to divide it as an authored layout needs. Tier TG runs against a fake server that reproduces the behaviours this code exists to work around — a split inserts its pane into the window list after the one it split, and a layout string's leaves are filled in window-list order with the ids in them ignored. What is not faked is the composite: the same layout string, the same swap decisions, and real control-mode lines from a fixture process through the same splitter and classifier. Both halves of the ordering claim were broken on purpose: removing the swaps fails TG2, and a fake that honours the leaf ids fails TG2 as well — so the row is passing because the composite imposes the order, not because the two happened to coincide. Stated plainly, and not claimed here: a fixture client inherits a pipe, so it cannot restore a terminal it never had. That a real `tmux attach` gives the reader's terminal back when asked to detach is #726's evidence on real tmux.
**The visible client is not a pane child.** A pane child is settled by sweeping its process group and its terminal, because a pane's terminal belongs to the grid. The reader's terminal belongs to the run: everything holding it is XMD, whatever started XMD, and the rest of XMD's foreground group. A settlement of that shape aimed at the attach client is a settlement aimed at the document. `attach-client.ts` owns exactly one process instead — asked to detach first, through tmux, and only then insisted on by pid, with no group, no descendants and no terminal sweep anywhere in it. **A successful `kill-server` is not proof.** Teardown now succeeds only once the recorded server pid is unreachable and the server refuses its own session, and throws a provider-neutral `TerminalTeardownFailed` when either is still unproved at the bound. The rule is in the resource finalizer too, so a preparation that failed halfway is held to it as well. **Nothing private in a diagnostic.** `TmuxCommandFailed` carries the step's name and nothing else — not the arguments, which hold the socket path, session name, pane and client identifiers and the worker's private directory, and not stderr, which tmux writes paths into. A provider's topology stays private on the paths taken when something goes wrong, which are the paths a diagnostic is read on. **Closures before removal.** The private directory is removed only after every accepted socket and every listening server has actually closed — counted from their own `close` events rather than from having been asked. Three regressions, each broken on purpose and re-run: - TG11 gives the process table company — XMD, its parent, two more in the same group, and four holders of the reader's terminal — and proves the escalation reaches the client's pid alone. Settling it like a pane child fails it. - TG10 plants markers in the socket, session, pane and client identifiers, the worker directory, the arguments and stderr, and proves none reaches the surfaced error. Restoring raw arguments fails it. - TG12 counts real closures at the moment of removal. Not awaiting them fails it. Also conformed to the repository's rules: `@effectionx/fs` for stat, rm, readTextFile and writeTextFile, with `node:fs/promises` kept only for `chmod` and `appendFile`, both adapted through `until`; the client fixture is an Effection operation; and the newly introduced `as const` assertions are gone in favour of typed values.
`end()` sent SIGKILL and then discarded what the wait after it established, so a client still holding the reader's terminal was reported as torn down. The shared `stop()` resolved successfully on top of that, and the document carried on. It now establishes the client is gone, and raises a provider-neutral teardown failure when it is not — so `stop()` rejects and the document stops instead. `leftWithin()` also looks once more at the boundary itself rather than falling back on the cached exit event: a client that left during the final interval is gone, and reporting it as still there would be reporting a stale reading. The boundary is unchanged and still narrow: detach is asked for through tmux first, and every signal after that names the exact client pid. Nothing inspects or signals its process group, its descendants, or the holders of the reader's terminal — on this terminal, each of those is the run itself. The refusal carries none of the socket, session, client name, argv, environment, terminal or host message. TG13 models a client that survives the ask, SIGTERM and SIGKILL: teardown refuses, the signals delivered are exactly SIGTERM and SIGKILL to the client's pid, three same-group bystanders and three holders of the reader's terminal are untouched, and no planted marker reaches the refusal. TG11's successful escalation is unchanged. Reinstating the discarded result fails TG13 and leaves TG11 green, which is the discrimination the two rows are for.
`provider.ts` is where #730's provider-neutral request meets tmux: it prepares the private channels, the hidden server and the panes, resolves each pane's worker command before a server exists, and hands core a composite it drives through its own lifecycle. Nothing tmux-shaped crosses in either direction. The reader leaving and the host's terminal going away settle the same `closed()`. That is deliberate: a hangup is not a second teardown path to keep honest separately, it is the ordinary structured close every other stop uses. The SIGHUP listener is a resource, so it is removed with the run rather than answering for a terminal the next one is using. `host.ts` states which hosts present grids. The Deno entrypoint and the compiled binary supply `foregroundTerminalGrid()`; every other caller gets `unsupportedTerminalGrid`, which still opens the installation so a grid is validated and refused by core rather than being silently absent. Node and Bun therefore catalog and validate the same grids and open none — threaded through `AgentStack` beside the machine-session assembly, which is the same shape this repository already uses for "Deno supplies the live one, Node and Bun supply the one that installs nothing". architecture.md's terminal-grid inventory row said "implementation unbuilt", which four layers had made untrue. It now says what each Story built, that the controlled provider remains the authority for core lifecycle semantics, that this Story's evidence uses a fake tmux with real tmux behaviour remaining #726's, and that Node and Bun install no operational provider. Checkpoint 3's evidence is not in this commit: the Node/Bun refusal row, the SIGHUP-through-host-installation row, and the CLI regressions are still to come.
… handling (#732) The teardown was the least-covered part of this provider, and covering it found two defects. A close *request* could fail outside the boundary that handled the waits. `socket.destroy()` and `server.close()` were called after their closure watch had been attached and queued, so a request that threw left a wait nothing would ever settle — the whole close hung rather than failing. The requests are now inside the same boundary, a watch whose request threw is abandoned rather than awaited, and the handle stays out of the closed set so a later call asks it again while leaving the ones that closed alone. A retried teardown restarted rather than resumed. Every phase was re-asked, so a worker that had already said goodbye and gone answered the second ask as "a worker that was gone" — and that answer replaced the reason the first attempt could not finish. The composite's own finalizer retries after a failed `destroy()`, so this was the ordinary path: a document was told its pane had vanished when what had actually happened was that the server would not stop. Phases that succeeded are now remembered, and a retry resumes at the one that failed. The teardown itself moves out of the composite closure into `createGridTeardown()`, which is what lets a row drive it with scripted workers over real private sockets. Rows: TH5 freezes the ordinary foreground-host branch — the same live grid as TH4, ended by a reader detach through the fake control channel instead of a hangup, asserting the exact result handed back through `useHangupCancellation()`. It fails if either the tmux provider or the POSIX observer is removed from `foregroundTerminalGrid()`. TH6 freezes entrypoint selection. Tier TD covers the combined teardown: shared in-flight teardown under concurrent destroys, the three protocol refusals, one pane's failure stranding neither the next pane nor the channels nor the server, first- failure preservation, the frozen order through to path removal, the retried close request, the resumed retry, and the document-level refusal. Real terminal restoration remains #726's real-tmux evidence.
…732) The suite hung forever under Node and Bun. Not failed — hung, which leaves a runtime shard running until the job's own timeout with nothing to read. A pane's worker is this executable re-invoked under the hidden `terminal-worker` subcommand, and only the hosts that present grids register it: the Deno entrypoint and the compiled binary. On Node and Bun the same argument vector names a *document* called `terminal-worker`, so the worker exits with ENOENT before it connects and the parent waits for a pane that will never say hello. It stalls entering TW3, the first row that spawns a real worker. $ tsx packages/cli/src/node.ts terminal-worker 0 <dir> ENOENT: no such file or directory, open 'terminal-worker' That Node and Bun install no grid provider is the design, so the fix is the exclusion this repository already has a mechanism for rather than a portable worker. Every other test file in this stack runs under Node unchanged; this is the only one that cannot. What the exclusion does and does not preserve, stated precisely because the rationale is the reason a later reader would trust it: provider absence is covered portably by TG9 in packages/core/tests/terminal-grid.test.ts, which runs on all three runtimes. TH6's entrypoint-selection freeze is textual, so proving it once under Deno proves it everywhere. TH3 makes the same claim as TG9 but is excluded with the rest of the file and proves nothing here. What is genuinely Deno-only is the worker, socket and fake-tmux integration.
DEC-016, as amended by 630c3f0a. The lifecycle and the one provider that implements it were spread across runtime, core and CLI, which made a second presentation provider depend on CLI internals and made the neutral authority look core-specific. They are now two ordinary workspace members: - `@executablemd/terminal` — native launch routing and reservation, grid and pane requests, composites and states, `TerminalGrids`/`TerminalProviders`, registration and direct authority, claims and readiness, row-major layout, the live and durable grid, pane routing, retained outcomes, the process observation contract and quiescence, the POSIX adapters, and the controlled fixtures — as root, `./lifecycle`, `./processes`, `./posix` and `./test`. - `@executablemd/terminal-tmux` — probing and commands, the hidden server and control clients, layout and swaps, attach, the authenticated channels and their protocol, the worker and its child, and the one ordered teardown. The arrows point at the neutral domain: terminal imports no runtime, core, CLI or tmux; terminal-tmux imports terminal and nothing above it; core imports terminal; runtime has no terminal edge at all; only CLI composes all four. `packages/cli/src/terminal/` is gone — the host composition that remains CLI's is `grid-host.ts`, which chooses and installs a provider rather than implementing one. Ownership moves, behavior does not. The stack has not merged, so the terminal exports that sat in runtime and core were never a compatibility surface — they were the ambiguity this removes. They are deleted rather than forwarded, every repository import names a canonical package surface, and no alias, barrel or wrapper keeps an old path reachable. Authored syntax, the provider name, the hidden worker verb, the protocol, durable records, diagnostics and every provider identity are untouched. TG21 is the evidence, and each half was probed by breaking it. The dependency rows read production imports from source rather than trusting a manifest, and fail when a `@executablemd/core` import is planted in the neutral package. The absence rows fail when a runtime terminal export is re-added or the old CLI path is recreated. The uniqueness row replaces what object-identity used to prove: with one import path left, the claim worth making is that there is only one definition to reach, so a second `createApi` or class cannot quietly split middleware composition between two objects that behave alike. A non-vacuity row keeps the absence claims from passing over an empty walk. Two things the work found rather than assumed. The fixture that runs the stand-in tmux client resolved its program through a repo-relative string; the move left it pointing at nothing, which started no client — and TG13, whose subject is a client that refuses to leave, passed anyway, because one that never starts never leaves either. It now resolves from its own module URL. And the uniqueness scan first keyed on names ending in `Error`, which reported `TerminalTeardownFailed` as having no definition at all; it matches any exported class now. Claude-Session: https://claude.ai/code/session_01CrKBYDBanPrxDqdQFgvFwS
… state (#717) Three Architect blockers on 3c10bc59. **The root was a facade.** `@executablemd/terminal`'s root re-exported a handful of neutral names out of modules that also spawned children and carried test doubles, so importing the domain loaded `node:child_process`, `node:process` and a fixture. Selective re-export narrows what is reachable by name and nothing about what is loaded. `launcher.ts` and `terminal.ts` are now five modules: `native-launcher.ts` and `composite.ts` hold the contracts and import no host API, `posix-launcher.ts` holds the foreground child, and `controlled-launcher.ts`/`controlled-composite.ts` hold the fixtures. Root, `./lifecycle` and `./processes` load none of the latter three; `./posix` and `./test` are where that code lives. Each descriptor and error still has exactly one definition — the split moved implementations, it declared nothing twice. **The tmux root was wider than its accepted API.** `paneEnvironment` decided which of *this invocation's* environment variables a pane inherits, which is a host's decision and not the adapter's; the adapter only passes along whatever `TmuxProviderDependencies.env` it is handed. It moves to CLI host composition beside its single caller, with the same allowlist, the same order and the same `TERM` default, so no pane's environment changes. **The lock state was incomplete.** The previous commit added two workspace members but only ran the Deno install, so `pnpm-lock.yaml` and `bun.lock` had no importer for either. Repaired through the documented procedure — `deno install --frozen=false`, then `deno task setup`, then `bun install`. Both locks now carry `packages/terminal` and `packages/terminal-tmux` and the five direct edges to terminal (acp, cli, core, terminal-tmux, test-agent); runtime has none, in its sources, its manifest and both locks. `publish-packages.yml` regenerates byte-identical, so the committed copy is already correct rather than corrected here. TG21 gains three rows for what the findings exposed. TG21i reads each entrypoint's transitive module graph rather than its export list — the facade passed an export check and fails this one — and TG21j is its discriminator, so the absence claim cannot pass over a graph emptied by deletion. TG21k pins the tmux root as an exact set rather than a set of required names, because `paneEnvironment` reached that root by being added to it and a required-names check would have let it stay. Probed: re-exporting the POSIX launcher from the root fails TG21i; adding one name to the tmux root fails TG21k. Claude-Session: https://claude.ai/code/session_01CrKBYDBanPrxDqdQFgvFwS
`deno task check:jsr` failed at 5163aa0c with five `missing-explicit-type` errors in `packages/terminal-tmux/src/pane-protocol.ts`. The extraction caused it: that module was internal to CLI and reached no published entrypoint, and it is now part of a published package, where JSR forbids slow types. An inferred zod type has no explicit form to publish. Hiding the re-export was not enough — JSR follows references out of the public API, and the exported `z.infer` aliases pulled the schemas back in. So the frames are declared instead: `Hello`, `Swept`, `Settlement`, `FromWorker` and `ToWorker` are written out, and each schema is held to its frame by a `z.ZodType<T>` binding. That is compile-enforced rather than a convention — changing `pid` to `z.string()` fails the typecheck at the binding, which is how I checked it rather than assuming. The schemas themselves become private. What crosses the package boundary is `parseFromWorker`/`parseToWorker`: a caller — including this adapter's own tests — needs "turn these bytes into a frame or throw", not the shape of the validator. The two internal callers and the one test use them now. No wire format changed. The declared frames are exactly what the schemas already produced, which is what the bindings assert, so the protocol, the worker grammar and every diagnostic are byte-identical. Claude-Session: https://claude.ai/code/session_01CrKBYDBanPrxDqdQFgvFwS
`@effectionx/process` writes every child's stdout and stderr straight to the host process — that is `Stdio`'s documented default — and consuming a stream does not turn it off, because the two are independent. The hidden `tmux -C` client's stdout *is* the control protocol, so `%session-changed`, `%window-renamed`, `%window-pane-changed` and every other record was being drawn on the reader's terminal and over pane prompts. Nothing about the grid looked wrong; the terminal just had protocol on it. The repair is one per-process suppression on the client the provider owns: a `stdout` handler that never calls `next`, which is how this repository already suppresses that default (`scripts/verify.ts`). The stream is still consumed and classified exactly as before, so no event, ordering or diagnostic changes. stderr is deliberately left alone. The two streams mean different things here — stdout is the protocol, stderr is the client saying something went wrong — and silencing both would make a grid that failed fail quietly. TG14 and TG15 are the pair, and each was checked against the other. TG14 watches this process's own stdout while the composite consumes four records, and fails on the unrepaired provider — it is a reproduction before it is a regression test. It asserts on the `%` records rather than on the exact lines this suite cares about, so it covers the protocol and not four strings. TG15 drives the client's stderr through a new fixture directive and proves the complaint still arrives; suppressing stderr as well makes TG15 fail, which is what says the repair is the minimal one rather than merely a working one. Adjacent, reported rather than changed: `tmux.ts` runs its commands through the same default with `.join()`, so real tmux command output would leak the same way. That is not reproducible under the fake tmux this suite uses, and no gate exposed it, so it is left for the Architect to direct. Claude-Session: https://claude.ai/code/session_01CrKBYDBanPrxDqdQFgvFwS
Every process this provider starts for itself now runs inside `quietly()`, which installs `Stdio.around` in the owning Effection scope before the child starts: the hidden `tmux -C` watcher, `tmuxAt().run()`, `tmuxAt().tryRun()`, and the default `tmux -V` probe. Both streams are suppressed. The visible attach client and every pane child are untouched — they inherit the terminal deliberately, and the scope is a child scope so the suppression reaches neither. Nothing about parsing changes. `client.stdout` is still read, split and classified exactly as before, and `run`/`tryRun` still parse the captured result; what is suppressed is forwarding to the host, which is a separate thing from the stream. Lifecycle ordering, provider identity, the protocol and the public errors are all as they were. Raw tmux stderr is never forwarded. A soft `tryRun` failure is still `undefined`, and a hard one is still `TmuxCommandFailed` naming the step and nothing else — no socket, session or pane, which is what tmux's own complaint would have carried. Five rows, at the boundary that matters: what this process writes to its own streams. TG14 covers the control records, TG15 that a complaining client stays silent to the reader (replacing its previous raw-stderr requirement, which the ruling reverses), TG16 the first record on attach, TG17 internal commands — success, soft failure and hard failure — through a `tmux` on PATH rather than the machine's, and TG18 a whole grid's life from startup through pane switching, detach, server disappearance and teardown, asserting no record and no private metadata. Every step is driven by a classified event; no row waits on a duration. Two things measured rather than assumed. Removing the suppression fails all five rows, so they discriminate its absence. But installing it on the handle after `exec()` returns still passes every row, including TG16 with a shell client that writes within a millisecond — the parent installs the handler before the child is ever scheduled, so the race the pre-spawn placement protects against does not occur here. The placement is still the one that cannot lose that race by construction, which is what the code and TG16 now say; what I could not do is produce a row that fails without it, and I am not claiming otherwise.
…717) The full Deno corpus failed at 319cad11: 914 passed, 1 failed, and the failure was `scripts/tests/test-file-discovery.test.ts` reporting two files the corpus could see that discovery could not — + "packages/terminal-tmux/test.ts" + "packages/terminal/test.ts" Deno's own test-file pattern matches a bare `test.ts`, so both packages' controlled-fixture entrypoints were test files as far as the runner was concerned: it would load them in every shard, and they sit outside the discovery that assigns the corpus to runtimes. That is the invariant that guard exists for, and the extraction introduced it by naming an entrypoint `test.ts`. The export specifier is what the architecture fixes — `@executablemd/terminal/test` and `@executablemd/terminal-tmux/test` — not the filename behind it. So the files become `testing.ts` and the `./test` exports point at them. No consumer changes: every importer already used the specifier, and the only references to the old filenames were the two manifests. TG21j reads an entrypoint by name and follows. Both files say why they are named that way, because the obvious tidy-up is to rename them back. No new row: `test-file-discovery` is the repository-wide invariant for exactly this, it caught this, and it passes now. A second copy of it next to the packages would be a duplicate rather than evidence.
Bun shard 2 failed at fd5f28cf on TG21h: `NativeLauncher` was reported as
having eight definitions rather than one, each of them the same file —
terminal/src/native-launcher.ts
terminal-tmux/node_modules/@executablemd/terminal/src/native-launcher.ts
acp/node_modules/@executablemd/terminal/src/native-launcher.ts
…
A workspace install links every dependency package under its dependents, so
one source file is reachable at many paths. My walkers did not prune
`node_modules`, and Bun's layout creates those links where Deno's does not —
so every row in this tier had been passing under one runtime for a reason that
does not hold under the other.
The count was the visible failure; the quieter one is `importsOf()`. It read a
vendored copy's imports as if they were the importing package's own, which
means the dependency rows — the ones that say terminal reaches no engine, host
or provider — were scanning code that belongs to terminal's *dependents* and
its own dependencies. They passed, but not for the reason they claim to.
Both walkers now skip any path segment naming an installed or generated tree,
which is the same pruning `scripts/tests/test-file-discovery.test.ts` does for
the same reason.
TG21l is the discriminator, asserting no walked file sits under `node_modules`
for four packages and repository-wide. Disabling the pruning fails TG21l and
TG21h; restoring it passes both, under Deno and under Bun.
Production code is untouched. This is a defect in the evidence I wrote, found
by a runtime whose install layout differs — which is the argument for running
the shards rather than trusting one runtime's result.
Preserve the host's non-empty COLORTERM in the restricted pane environment, and preserve an omitted native-launch environment through the private worker protocol so the child inherits that environment. Explicit environment maps remain exact.
An advertised adapter name selected a command shape and, by omission, authorized acting on a session a native UI may be in. DEC-017 makes the authority a point — adapter, capability, exact reported version, host OS and architecture — so one real-CLI proof admits exactly what it proved. The host states the machine as a value beside the coordinator, route store and observer, so shared provider and session assembly never detects the runtime it happens to be on.
A native launch acknowledged ownership quiescence whenever `stop()` on its scope succeeded. A launch that completed naturally tore itself down first, so a finalizer that failed on the way reached the owner as a crash while `stop()` afterwards succeeded against an already-settled scope — proof that the scope is settled now, never that the cleanup which failed had succeeded. The session was released idle with an unproved child behind it. The invocation now suspends once the native child settles, so the one teardown there is happens under the one call that reports it. Quiescence is still withheld unless the child settled, every finalizer of that invocation completed, and the provider holds no handle; otherwise the exclusion is released with the record left active, and the next acquisition is refused as session-recovery-required before any spawn, ensure, allocation or route work. The original cleanup failure is what the launch caller observes.
An exact-version compatibility point admitted a capability from the release string an executable happened to print. That is the wrong evidence twice: a release name says nothing about which operations a build declares, and pinning one refuses every later build that declares the same shape. Admission is now four exact facts — the adapter's own versioned protocol, the capability being asked for, the profile its observer returned, and the host's platform and architecture. An Agent registry name or a launcher command admits nothing. The two Claude capabilities are read independently, so neither is ever inferred from the other having been proved. The host-owned observer is generalized: it resolves and canonicalizes the launcher, requires an executable regular file, hashes it once, and asks that same exact path whatever read-only queries the adapter declared, returning settled status and captured output. It knows no provider and parses nothing. Reading Claude's `--help` structurally — caller-supplied identity, exact resume, private-file instructions — stays in the adapter, so additive options, prose and wrapping do not change the answer. Version becomes optional evidence beside the digest rather than a gate. A build that will not name its release, names it in words this adapter does not recognize, or names several is bound by its bytes alone. Build equality is asymmetric: the digest decides, a retained release is a claim the live build must still make, and a record that named none is not rewritten when one appears.
The help probe read flag spellings. `--session-id <name>`, `--resume <url>` and `--system-prompt-file <text>` all satisfied it, so a build that accepts the spelling and means something else by it was admitted for native launch — and the launch would then hand a UUID to an option that takes a name. Each declaration is now parsed into its spellings, its value placeholder, whether that value is required, and its own description. Chosen identity needs the sole `--session-id` to require a UUID. Exact resume needs the sole `--resume` to name session identity, either in its placeholder or, for the uncommitted `[value]` spelling Claude ships today, in that entry's own words. Private instructions need a file-valued `--system-prompt-file`, or the `--system-prompt[-file]` family spelling inside a parsed entry. Product is read from dedicated unindented lines, so a compatibility claim or a quoted usage example no longer says what this executable is. Two entries declaring one spelling is unreadable, not a choice between them.
Three surfaces that say the opposite of the contract were admitted by it. `Resume a conversation by URL; session ID is not supported` contained both words the read looked for. `Inline text; file paths are not supported` was accepted from a description while its argument was `<text>`. `Claude Code compatibility wrapper` began a line with the product name. Prose is now read one clause at a time, and a clause that withdraws what it states admits nothing: a sentence states one thing, and its neighbours are not it. A generic `[value]` resume needs the phrase saying the argument is what the conversation is named by, not two words scattered through it. The private instruction file is decided by the argument alone, since a description explains a value rather than changing it. A product line names its subject and then describes it, so words running on into a longer name have named something else. The 2.1.241 and 2.1.263 surfaces, additive options, wrapping and unrelated prose are unchanged, and no version is consulted.
A route's `ExecutableBuildBindingV1` was a release lock: every continuation held the live observation against the retained one, so upgrading Claude stranded every session the previous build had opened. It is now immutable audit evidence — written once at first publication, never rewritten, and never compared with a live build. What authorizes acting on an existing session is what the installed executable independently proves: the route's stable adapter protocol, the capability the work needs, the shape its own probe recognizes, and this host. Which build accepted the identity first is history, and history is not a capability. Removes the four live-vs-retained comparisons (attachment, native resume, concurrent winner, replay) and the `buildDrift` helper they shared. The one remaining comparison stays: a route and its prepared journal are two durable accounts of a single observation, so a difference between them is still a replay that cannot say which session it is resuming. Tier XR takes a 2.1.261 session to a 2.1.263 build across native resume, ACP attachment, prepared-only replay, detached replay, a concurrently published winner, and a build reporting no release — each asserting the exact retained identity, unchanged audit evidence, no allocation or republication, and the live executable's own path. XR7 and XR8 hold the fail-closed boundary that a release change is not a protocol change.
A V2 construction route fixes the stable protocol its identity was published under through its provider, agent and launcher contract. Continuation read that protocol off whatever adapter happened to be registered under the launcher instead, so an adapter declaring another protocol — beside a host policy that had proved exactly that protocol, for that capability, in that shape, on this machine — could adopt a session it never constructed. ACP attachment also never held the route's retained launcher to the live adapter at all. The pin is compiled in and reachable through no host-supplied dependency, and it is asked before the executable is observed: which conversation this is was never a question about a build. Native resume, ACP attachment, concurrent-winner adoption and both incomplete replay phases now require the live adapter's launcher and protocol to be the ones the retained contract names, and the independent admission of the current executable's capability, shape and host follows it. A refusal observes nothing, allocates nothing, launches, ensures, prompts and constructs nothing, and leaves the route and the journal exactly as their first publication left them. Executable bindings stay what they became: immutable audit evidence, and a cross-check between two durable accounts of one observation — never a live same-build gate. The source contracts that still described them as one are reconciled. Evidence: XR9 takes an injected adapter speaking another protocol, with the host policy admitting that protocol, through native continuation, ACP attachment, both replay phases and concurrent-winner adoption, and asserts the executable is never even observed. XR10 refuses an attachment whose retained launcher is not the live adapter's. Planting each seam's absence back one at a time fails only that seam's case, and reading the pin off the adapter fails both cases and nothing else.
An empty route read settles nothing: a concurrent publication is only revealed by publishing. Until now a run whose read found no route fell straight through to observing the executable, allocating an identity and writing a candidate, and asked whether it could account for the protocol only after meeting the winner — having already acted on behalf of a session it was never entitled to construct. The pin is now asked twice from one reading. Prospectively, before any observation or allocation, against the live adapter's launcher; and again of the record that actually won, whose route may differ from the candidate's. The legacy V1 path is untouched. Reconcile the remaining same-build continuation wording across the touched source set. A retained binding is immutable audit evidence and is only ever held to the prepared journal derived from the same observation, never to a build installed later.
`packages/terminal` is created by this stack's extraction commit, which was authored while the repository pinned `@effectionx/node` 0.2.4. Main has since moved to 0.2.5 (#748). A file a commit creates conflicts with nothing, so the rebase carried the stale pin forward beside a root that now asks for 0.2.5, and `deno task setup` refused: the frozen lockfile carries no `npm:@effectionx/node@0.2.4` specifier for a workspace member to resolve through. The pin now matches the one every other package and the root already use. The 0.2.4 entries that remain in `deno.lock` are `@effectionx/process@0.8.1`'s own transitive dependency, and they are present on main unchanged. No other dependency version moves.
Three call sites this stack added kept compiling against surfaces main has since changed. Each pair of edits touched different lines, so the rebase merged both sides without a conflict and the mismatch only surfaced under `deno check`. `xmd plan` used to settle a whole agent stack through a local three-argument helper. Main replaced that with the authorship-only resolver, which takes two arguments because the command starts no program, so the terminal-grid installer this stack threaded through no longer has a parameter to occupy. `xmd run` is unchanged and still installs it: the installer reaches `installRunAgentStack` through `resolveAgentStack`, which is the one path with a terminal to give away. The syntax suite's two terminal-grid rows still named `syntaxCatalog` and the local `parseCatalog`, which main renamed to `syntaxSymbols` and `parseSymbols`. Only the names move; both rows assert the same origins, authored forms, descriptions and reserved-name exclusions they always did. The launcher suite imported `reap` from the provider-neutral contract after this stack split the module in two. `reap` ends a POSIX child, so it comes from `posix-launcher.ts` beside `installForegroundLauncher`, and the contract import keeps only what a consumer that never reaches a process needs.
The rebase reintroduced fourteen listeners the ownership rule refuses: a raw `once()` in the POSIX launcher's spawned callback, subscriptions whose `ensure()` was yielded after them — itself a suspension an owner can be halted in — removals hidden behind a helper the pairing cannot be read from, a self-removing socket handler a cancelled scope never reaches, and three process signals registered under a computed name. Every one of them now names its receiver, event and handler on both sides, with the removal established before the subscription or held by a `finally` around it. The launcher keeps its inline exit/error race, its complete reap and a spawned callback that fires exactly once: the handler comes off when the start arrives and again on every way out, so a cancelled or failed launch publishes no readiness at all. `usePaneChannels` remembers each accepted socket with the handler it carries and detaches every one of them at teardown, including a socket that closed on its own. FL10 reads the child's own listener counts after delivery, after a startup failure and after cancellation, then replays `spawn` on it: restore the raw `once()` and the row fails. TW14 already held the tmux half, and fails if the new channel detach is removed. Terminal-grid TG3 expects the catalog version main's Syntax v2 migration publishes. Co-Authored-By: Claude <noreply@anthropic.com>
|
Closing this redundant integration surface: after rebuilding the native GitHub stack as #734 → #733 → #738 → #741 → #747 → #771 → #764, GitHub evaluates the top PR against the stack trunk ( |
PR #772: 🧪 Verify the complete terminal-grid stack on main (#717)120 files, +23262 / -987 Scope🔴 PR has 24249 lines changed. Split into focused PRs. 🟡 24249 lines changed. PRs under 400 receive more thorough review. 🟡 120 files changed. Are all changes related? 🟡 Changes span 17 directories. 🟡 PR mixes config and source changes. 🟡 New abstraction files: packages/terminal-tmux/src/provider.ts, packages/terminal/src/provider-api.ts. Verify 3+ consumers. Structural🟡 Type declarations with no consumers: ExecutableRefusal.
Oxlint structural signals:
SlopOxlint slop signals:
Static AnalysisOxlint: 158 diagnostics across 33 files (24 rules) no-unused-vars (28): packages/core/src/agent/function-components.ts, packages/test-agent/src/components.ts, packages/acp/src/provider.ts (+4) CorrectnessNo extraneous code patterns detected. |
Why
The #717 terminal-grid work is split across stacked pull requests, whose CI does
not exercise the complete composition against
main. This integration PR givesthe entire rebased line one reviewable head and one authoritative delivery run.
What changes
Before:
mainand its stacked PRs did not runthe full
maindelivery workflow together.After:
continuation form one signed, merge-free line on current
main;ownership contracts;
integration tip;
How it works
This PR is an integration and verification surface. Review remains organized in
the stacked PRs:
Review guide
Start with: the individual stacked PR matching the subsystem under review.
Then review:
packages/core/tests/terminal-grid*.test.tspackages/terminal/tests/package-boundary.test.tspackages/terminal-tmux/tests/terminal-grid-tmux.test.tsLook carefully at:
What must stay true
surfaces.
How to verify it
deno task lintanddeno task checkpass at this head.44826a98,including native-launcher, tmux-grid, terminal package/host, syntax catalog,
document validation, and syntax CLI evidence.
and aggregate
greenverdict.Scope
Included
composition
mainIntentionally unchanged
Risks and limitations
provider session identity before consuming prompt input. Make Claude native-to-ACP continuation preserve the exact conversation #770 remains blocked
on the upstream SDK capability and the combined line must not merge while its
positive cross-release claim is unproved.
branches retain their rebased cut points.
Scope confirmation