Skip to content

Give the REPL a responsive workspace and independent Stops - #1004

Merged
taras merged 53 commits into
mainfrom
agent/repl-layout-971-20261027
Oct 29, 2026
Merged

taras merged 53 commits into
mainfrom
agent/repl-layout-971-20261027

Conversation

@taras

@taras taras commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Why

Keep the conversation, selected work and next Draft readable together, and let a person stop an entry independently of a Sidekick Send.

Refs #971. This delivers its accepted layout and Stops slice; Follow and shared queue delivery remains with #964.

What changes

Before: the REPL used a Sessions/Entries sidebar, separate reading panes and a seven-row footer. Leaving an entry unfinished preserved recovery but did not provide a final Entry Stop.

After:

  • The accepted 160x36 and 120x30 profiles show one Transcript list with an All sessions filter, selected work with breadcrumbs and its Draft, and Bindings/Sidekick tabs. History is one compact row with contextual guidance below it.
  • At 72x20, one routed outlet is drawn. Smaller terminals show a refusal and recover on resize, preserving the Draft, selection and focus.
  • Entry Stop retains an interrupted outcome and joins cleanup before another entry can run. Sidekick Stop ends its Send while an already admitted entry remains independent. Exit continues to leave unfinished work recoverable.
  • Long multiline Drafts fit only the logical lines needed for the visible tail, using a separate fit store. The staged screen also avoids measuring the unused ordinary History band.

How it works

acknowledged journal reading
  -> selected prefix and workspace
  -> measured rows and admitted controls
  -> mounted frame and terminal output

The session owns entry admission and Stop, consuming the host Stop capability merged in #1008. The Sidekick owns its Send. Presentation reads their retained facts without combining their histories or creating another execution owner.

Review guide

Start with: specs/repl-spec.md, especially the terminal profiles, compact History and two Stops.

Then review:

  1. application.ts, layout.ts and presentation-style.ts for the workspace, drawers and narrow outlet.
  2. session.ts and program.ts for admission, independent Stops and joined cleanup.
  3. fitting.ts and the Draft fitting path for exact wrapping and reuse only at the current width and size.
  4. The three appearance records, mounted behavior tests and deferred-presentation ledger.

Look carefully at: exact retained-prefix readings, modal focus, absent controls having no targets, and a stopped entry admitting no successor until its terminal and cleanup are complete.

What must stay true

  • History navigation may name later positions, but their content and capabilities cannot cross the selected prefix; source-reading and History cases cover this boundary.
  • Resize preserves drafts and selection. Hidden outlets and unadmitted rows have no cells, targets or traversal stops; layout/admission and mounted presentation cases cover them.
  • Entry and Sidekick Stops keep their separate owners. Lifecycle and journey cases retain cancellation, independent admitted work and joined cleanup.
  • Text uses measured wrapping and complete fitted rows; Configure fitting and source-reading regressions remain active.

How to verify it

Recorded focused results at c87c487f:

  • Node layout/admission/terminal/presentation: 151 passed; journeys and interaction: 511 passed.
  • The five affected files under Deno: 97 tests / 269 steps passed; under Bun: 269 passed.
  • Lint, format, Deno and Node typechecks, architecture and dead-code checks passed.
  • Required green CI passed on this head. The posted review's C1-C4 corrections are integrated; C5 remains the optional queue/Follow deferral.

The saved 300-line Draft workload at 160x36 measured input-to-terminal-write completion at 11.4/13.4/14.2 ms p50/p95/worst, with 40 inputs and 40 writes, versus 61.9/116.6/146.5 ms and 20 writes before the correction. All three sizes recorded 40 writes. Twenty of 21 captured screens matched after removing the session-id line; the remaining mid-phase capture showed the old head lagging, and every settled screen matched.

Scope

Included

  • Accepted responsive layout, one-list Transcript and session filter, compact History, Entries catalog, breadcrumbs, Draft and current Sidekick controls.
  • Independent Stops, retained interrupted entries and existing recovery distinctions.
  • Three validated appearance records bound to one canonical journal, separate mounted behavior evidence, and explicit ownership/reactivation records for deferred presentation coverage.
  • Test launcher and fixture updates supporting the changed CLI interactions.

Intentionally unchanged

Generated or mechanical changes

  • The three profile JSON records encode expected styled rows for the validated journal fixture. Their provenance and coordinate/style authority are recorded separately from mounted behavior tests.
  • Fixture and journey updates follow the new controls; ordinary-kernel composition coverage remains explicitly labeled.

Risks and limitations

  • Performance measurements describe one host and one run per size at terminal-write completion and pty arrival. Visible emulator paint was not observed, so these numbers do not claim display FPS.
  • The broad local Node run preserved three publication failures also seen on the prior head and a separate undiagnosed repository-suite failure. That run is not reported as green; the current-head required CI result is linked above.

Scope confirmation

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

taras and others added 21 commits October 27, 2026 07:17
…/Follow decisions

Record the v2 composition (Transcript, selected work with its own Draft,
Bindings/Sidekick with TODO/QUEUE, Follow and the Sidekick's Send/Stop, compact
History and guidance) as the staged composition under image review, replacing the
sidebar and seven-row footer as universal geometry.

Record the five accepted decisions: Follow is on by default and runtime-local,
only Sidekick submissions take part, continuation is owned by the evaluation
Send invokes and supplies actual outcome and output separately, a queued
message precedes another autonomous round (#964), Sidekick Stop ends the Send
and its continuation while Follow stays on, and Entry Stop finishes an entry as
interrupted so a successor may follow once its cleanup joins, while Exit keeps
unfinished-entry recovery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Draw the v2 composition through the production pipeline when a wide view
carries the private, inert workspace presentation: the Transcript of agent
work with owner groups and origin marks, the selected work with breadcrumbs,
state, exact source and its own four-row Draft with Stop, and
Bindings/Sidekick with associated work, TODO/QUEUE, checked Follow, the
Sidekick's Stop, wrapped queue rows with Remove message controls and the
composer, above one compact History row with the contextual actions and one
guidance row.

The root never supplies the workspace data, so the ordinary composition is
unchanged; TODO, QUEUE, Follow, Remove message and the Draft's Send/Stop are
mounted controls that ask the root for nothing. One controlled checkpoint
fixture and one focused case cover C1/C2; geometry and palette await the
Product Owner's image review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Match the #971 reference: a blank display row between each message, reply and
the work it started (checkpoint discrepancy D14). Gaps are display rows only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The checkpoint screen selected only the entry root, so its breadcrumb said
"Entry 2" and exercised none of the ancestry presentation (R1). Its view also
carried a focused label the mounted tree never honoured: the tree focused
sessions:earlier while the Draft drew the focus cue (R2).

The running entry now invokes the Checklist fixture component, so the model
admits a real component scope, and the route selects the scope the model
actually holds. The screen is committed in the two frames the product takes:
one whose Draft claim takes focus, then the reviewed frame composed with the
key the tree answered with. The capture tool and the focused case share that
one sequence, and the case asserts both the nested selection with its drawn
breadcrumb and the mounted focus key.
The whitespace backoff ran on every cut, including one whose measured prefix
already reached the end of the remaining text. A tail the engine had accepted
whole was shortened anyway, so `it asks for.` came out as `it asks ` and then
`for.`, and a remainder exactly as wide as its room took two rows.

Backing off is for a prefix. Where the measured prefix reaches the end of the
whole remainder, that remainder is the row: nothing follows it for a boundary
to put on a row of its own. The end of the remainder decides it, never the end
of the bounded window the search was asked over, which can stop short of it.

Overflowing words, hanging indentation, whitespace retention, grapheme
boundaries, reservation widths and measurement budgets are unchanged. Both
production entry points — fitPlain and prepareReading — carry regressions.
Pin the accepted 160x36 screen as literal expectations transcribed from the
capture the Product Owner accepted, so a palette or geometry change that moves
it fails against the reviewed appearance rather than against the constants that
produced it.

Extend the same composition to the two smaller review sizes. Medium keeps three
columns at the plan's proposed 36/34/50, stated rather than grown: a growing
column takes its minimum from its content, and a breadcrumb is content. Narrow
routes one outlet under the two surface controls, with the Draft following the
selected work and the conversation its own tray and composer.

A header whose two groups do not fit one row takes a second row of its own
pane's body, left group first, decided by fitting the row's text to the width
the engine measured for it. Drawer rectangles follow the staged body's three
chrome rows instead of the ordinary footer's seven. Outside the accepted wide
screen each row states one growing child, because two split a remainder in the
engine's own arithmetic and that arithmetic is not whole.

An interrupted entry reads as its own outcome rather than as a failure.
The queue grouped each queued message into one box, however many rows
that message's text took. A box the window clips still reports its whole
rectangle, so the row after the viewport was placed over the withdrawal
control below it — measured at 120x30, `queue:later` at x90 y24 w29 over
`queue:q3:remove` at x116 y24 w3.

The window already counted fitted rows. Now the description does too:
one box per row, walked in description order, with the first row of each
message carrying its control and the continuations carrying none. The
whole sequence is described when there is no window, because a viewport
whose height is declared is still a viewport the renderer measures — and
describing nothing there left the queue's text unmeasured, which
collapsed its stated width.

The Draft's field keeps the remainder its heading measured, and is drawn
in one ink: stating a width is what makes the builder apply a row's
stretches, and the marker would otherwise take the focus ink where the
reviewed screen draws the whole heading in the field's.

`box:queue:text:0` is one row high rather than two in the pinned
accepted screen, which is the row unit rather than the screen: every
pinned row, every styled cell probe, and that box's own x, y and width
still read exactly as captured. The rasterized 160x36 screen is
byte-identical to the accepted capture.

A2's two wrong expectations are corrected to the fitter rather than the
other way round: the boundary space stays on the row that ends with it,
and the last row of the long line is its whole remainder.

R1. The resize journey committed the opening frame again at every size,
which let the Draft take focus four more times — a screen that restored
focus rather than one that kept it. It now carries the key the tree
answered with, and one seeded state: the position this model holds, the
nested scope under it, and two texts no default supplies. That position
is the present one, because resolution refuses a route naming any marker
the model was not projected at, and a view frozen at a prefix shows
nothing of the present.

R2. Both windows are walked rather than described. The tray's hides its
last message at first — no row, no control, no mounted node — and moving
it draws that message with its own withdrawal control, on the row the
message begins on, inside the window that admitted it. Every drawn row
is checked for its own targets. The tray that fits keeps no window and
neither navigation control. The chat's window is walked a position at a
time, reading each outcome's ink the first time it is on the screen: all
four render, no two in one ink.

R3. The question's drawer is opened by the action its own control
carries, against what the frame measured and which drawer it presented —
a field action reduced without the frame that drew the field is refused,
which a fixture that set `asking` itself could not have noticed. Its one
parsed field then takes the focus and the text. `askingState` is gone.
The overflowing tray's viewport was measured with every fitted row
inside it and neither navigation control outside it, and the admission
was derived from that measurement. Measured at 120x30, the last pass
put `box:queue:viewport` at y15 while the final manifest puts it at y16,
with Earlier at y15 and Later at y24. The heights agreed, so nothing
visibly moved — but the rectangle that authorized the admission was not
the rectangle the frame describes, which is the whole of what the
measurement contract asks for.

Two things were conflated. Whether the tray overflows was read from an
already-admitted window, so no measuring pass could know it and none
drew the controls. It is read from the declared allocation now — the
same answer, one step earlier, and the same one the admitted window
reports.

And one pass was being asked both how wide a queued row's control is
and how many rows the tray can hold. A scrolling viewport is measured
empty, and an empty viewport holds no control to measure, so these are
now two passes: the width probe draws the sequence's first row and
admits nothing, and every other pass leaves the viewport empty. The
probe is no longer reusable as the structural skeleton, so that
skeleton is measured rather than inherited — one more pass, still not a
loop.

The staged admission case now watches the preparation's own
measurements go past and says what the last one holds: no rows, both
controls, and the rectangle the final manifest places the viewport at.

Nothing else moves. 0 fractional rectangles, 0 accepted-screen
mismatches, 0 overlapping targets at 120x30, and the window still
reports capacity 8 over a declared 8-row viewport.
The pass that answers what the widths are reads a queued message's text
width off that row's own box, and the previous change stopped it from
describing one: every pass but a probe now leaves an overflowing
viewport empty, and this pass was not marked as a probe.

It only shows when the tray is over its budget *before* anything is
fitted. The controlled overflow case wraps six messages into twenty
rows, and six rows fit, so that pass described a row and nothing was
wrong. Nine short messages are nine rows either way — so the width came
back zero, the fitter was skipped for having no room, and the tray
reported a capacity of eight over a total of nothing and drew no
message at all.

The width pass is a probe: it admits nothing, and its row is blank
until something has been fitted, so it makes no column wider. The
capacity skeleton is unchanged — still an empty viewport with both
navigation rows around it and the bounds the final manifest places it
at.

The overflow regression gains that case: nine short queued notes, a
measured width above zero, nine fitted rows, eight admitted and drawn
with their own withdrawal controls, and the ninth reached by moving the
window. Without this fix that step fails, which is the whole of why it
is there.
The journey said it carried an exact history position and did not. The
fixture projected its journal only at the present, so `model.selection`
was undefined, `seededState` copied that, and `expect(at).toBe(
model.selection)` compared undefined with undefined. Every later size
preserved the same nothing.

A route may only name the position its own model was projected at, so a
retained reading is a second projection of the same events rather than a
marker set on the model of the present. `checkpointEvents` returns those
events and `checkpointSelected` returns both halves of the pair, at the
position where the selected entry's own nested scope was admitted. The
journey asserts the marker is a string, is one this journal retained,
and is the one its model was projected at — all three, because any two
of them agreeing on undefined would say only that this is the present.
It then carries that state and the tree's own focus through 160, 120,
72, below the minimum and back, and reads the retained reading off the
committed cells: the History band says it is reading earlier.

That journey turned up a frame the renderer refuses. A reading of a
retained position makes the band say so, and at 72 columns the row
cannot hold 52 cells of band beside 30 of controls — the engine squeezed
the fitted map to 51.0078125 and nothing could be drawn at all. The map
now takes the remainder its own measured controls leave, the way the
Draft field and the queue's text already do, so the row is whole and the
band is cut at a cell rather than at a fraction. The accepted wide row
still fits its own map and is unchanged.

What falls off that cut at 72 is the phrase that says a retained
position is being read. The band has no narrower wording to fall back
on, and choosing one is an appearance decision for the image review
these sizes are already waiting on. The journey asserts the phrase where
the row has room for it, says so where it does not, and holds the
reading itself unchanged at every size.
That row is a fitted map, a gap, and the controls — and the gap and the
controls both grew, so the engine divided what the map left between
them. It divides in halves, and half of an odd number of cells is not a
cell. The accepted fixture's map leaves 126, whose halves are whole, so
nothing said the arrangement only worked for even remainders.

An ordinary root's map is 25 cells of a 160-cell row. That leaves 135,
which came out 67.5 and 67.5, and put History at x=92.5 — a column that
does not exist, which the renderer refuses rather than rounds. Every
control after the gap landed on half a cell, so the screen did not draw
at all. The room was never the problem; the division of the unused space
was.

So the gap is declared and the controls stay the row's one growing
child. The width is an integer budget decided before anything is placed,
from this frame's own measured row and fitted map: no returned rectangle
is repaired, no label's width is guessed, every control is still fitted
to its own label, and the engine still measures how many of them there
is room for. The odd cell goes to the controls, which are what somebody
is aiming at.

The accepted wide screen is unchanged — its remainder was already even,
so its gap is still 63 — and the medium and narrow rows, whose
arrangement is different and whose rulings are closed, are untouched.

The staged admission case gains the reading that found this: an ordinary
empty root at 160x36, whose measured remainder is asserted to be odd
before the frame is committed at all. Without the constraint that step
fails on the refusal.
A generator's return cannot be observed by the `catch` that writes a
root's terminals, so a halted root has always unwound without recording
one. That is what leaves an interrupted run resumable, and it stays the
behaviour of every caller: absent the new hook, nothing here changes.

What was missing is the other ending. The representation already exists
and every reader handles it; nothing wrote it. So `durableRun` now hands
its own root control to one trusted caller that asks for it, minted over
this call's own context, replay index and terminal state. The control
carries no stream, no append and no event — what a holder may do is ask,
and read back what actually settled.

The ask is answerable only while the root is open. A terminal already on
its way to the stream keeps the ending it was writing: cancelling that
append and compensating would be a second terminal written over a
backend that may already hold the first. An accepted ask arms a
finalizer registered before the workflow, so it unwinds after everything
the workflow owns, and appends exactly the close the protocol already
has — root, cancelled, status and nothing else — through the same scoped
append and the same guarded journal as the ordinary terminals.

It makes the same refusals they do. A sticky durability failure is
already this root's outcome, and retained history the definition did not
align with is divergence rather than a stopped run; neither is papered
over with a cancelled close.
Four things the first version got wrong, each of them measured.

An accepted request lost to whatever happened next. A workflow that
returned afterwards appended its ok close and the root settled ok, as
though nothing had been asked. A request accepted while the root is open
now takes its ending exclusively: neither ordinary path acknowledges a
terminal around it, and the value the workflow computed still reaches
its caller without becoming this root's recorded ending.

Cancellation swallowed outcomes that were not its to swallow. A sticky
durability failure, and retained history this definition never reached,
both left the finalizer by resetting the phase and returning — so the
run ended quietly and whoever asked it to stop learned nothing. Both
now leave by their own identity: the durability failure itself, and a
TerminalDivergenceError carrying the retained coordinate it did not
reach. Neither appends a close, and neither is reported as a stop.

A failed or ended root could be asked again, because those branches put
it back into an accepting phase. It has an `ended` phase now, and it
never returns to `open`.

And a control outlived its owner: an ordinary halt left it accepting
requests against a run that had gone. Retirement is registered before
the cancellation finalizer so it unwinds after it, and the control is
published only once both are in place. What was retained stays readable;
what it will no longer do is accept.

The already-appending winner rule, the absent-hook default and the
status-only cancelled record are unchanged.
Two things the state machine still got wrong.

A root whose ending a Stop already owned still handed its workflow's
value back. The journal said cancelled and the caller was told the run
had succeeded — the one thing an interrupted root must not do for
anybody. It now settles stopped and leaves as an interruption: no value
is published, no ok close is acknowledged, and the catch lets that
leaving past untouched so no compensating error close is written around
it.

The settlement is shared by the two paths that need it rather than left
to the finalizer alone. A frame that leaves by throwing cannot rely on
an append started while an error is already unwinding: measured, the
close never reached the stream and the control still reported nothing
retained. So the return path settles first and then leaves, and the
finalizer still covers the root that was simply unwound.

And cancellation divergence was thrown without being remembered. The
same context went on accepting appends, so a history that had already
diverged could still be written into. It is remembered in the shared
durability state before it is thrown, exactly as a cancelled child's
divergence is, and every later append through that context is refused
by the same failure, by identity.

The already-appending winner rule, retirement, sticky-failure
propagation, the absent-hook default and the status-only cancelled
record are unchanged.
The person who accepted this screen asked for one list rather than two
headed sections, with All sessions filtering it. The staged Transcript
draws every owner's turns in one list now, and each row still says whose
work it is and which conversation it was in — the two owners keep their
own authority and their own recorded order, and nothing merges their
histories.

The selector was a caption. It is a control: activating it draws the
conversations the reading holds, choosing one filters the whole list
across both owners, All brings them back, and putting the choices away
chooses nothing. Whether they are showing is process-local, beside the
question form, because an open chooser is a fact about somebody at this
terminal rather than part of a location.

A header fitted its one row counting one gap where the drawn row has
two, so a header exactly one gap too wide was called a fit and the
engine squeezed a label onto a fraction of a cell. It counts both now,
and the two-row fallback fires where it should.
The record a stopped entry leaves has existed for a while and nothing
wrote it. Now the Draft's Stop carries the live head's own entry key to
the session, the session asks that entry's own execution, the execution
asks its root while the root is still open and halts the runner the root
writes from — so the cancelled ending is appended through the journal
that run still owns, and nothing above it constructs a Close.

The slot stays occupied for the whole of it: the terminal has to be
retained and every resource the entry held released before anything may
follow. A stop whose append or cleanup failed is not a stop that
succeeded, and the session admits nothing after one. Exit is untouched
and still leaves an unfinished segment to resume.

An interruption also stopped being a type and became an object. Any root
throwing a RootInterrupted took the terminal bypass, so a root that threw
somebody else's left its segment with no ending at all; it is this run's
own object now, compared by identity.
Three boundaries were answering the wrong question.

A root that refused the interruption was halted anyway. Refusing means
its own terminal is already being written, so the append was cancelled
over a backend that may have held it and the run was reported stopped
when it had succeeded. A refused ask is now joined: nothing is halted,
nothing is closed and nothing is settled, and the ending it was writing
is the ending it has. Only an accepted ask — or a run with no root yet —
halts the runner.

The invocation's stop then discarded the completion it had waited for,
so a durability failure could be reported as a successful stop or lose
to an ordinary cleanup error that happened after it. It ranks kind first
and occurrence within the kind now, by identity, the way the completion
beside it was already reconciled.

And the session trusted that answer for settlement. A stop joins a task;
what makes an entry settled is a terminal the journal acknowledged, so
the acknowledged reading is consulted before the slot is released and
before anything may follow.
The staged screen's reachability and four of its measurements.

The breadcrumb was one inert line. Every name in it but the innermost is
now a control that selects its own prefix of the scope path — the same
path that scope's catalog row selects, so reaching it from the trail and
reaching it from the catalog are the same act and the published route
says so. The innermost stays a line: the reading is already there, and a
control that selected where you are would do nothing. A reading with no
nested scope has no ancestor, so that screen is the one it was, and the
trail's separators are stated once on the part each leads to.

The narrow directions lost their last fact. `box:guidance` wore the
workspace *list* gutter, so a 72-cell frame offered the band 70 and the
sentence is 71 — the fitter moved `Tab to draft` to a second row the
two-row footer has nowhere to put. The band is read in one cell from the
left, like the History row above it, and holds nothing back on the right.

A turn revealed from a frame with no chat window landed nowhere. The
reveal searched a row list built without this process's chat staging —
measured, a twelve-turn chat the frame drew as 47 rows came back as 24,
and the index found in the short list named a different row in the long
one. It is given the staging the description pass gives the builder.

And the Transcript's selector drew a provider's session key unbounded:
58 cells in a 34-cell column, overflowing its row and leaving the engine
placing it on a fraction of a cell, which this product refuses to draw at
all. It is fitted to the room its own row has, and stated at the cells
its label says — the chevron survives, because a selector that gave up
the mark saying it opens is one nobody can tell is one.

The suites that reach these screens are reconciled to them: one
definition of the catalog drawer, the rail's marks and the selection
marker for both files; readings bounded by the pane that draws them and
by the terminal's own size, which after a shrink is not the buffer's; the
narrow outlet's one reading asked for where it is drawn; and the pane's
own Send distinguished from the Draft's.

`accepted-160.ts` keeps every bound and every other cell of the accepted
screen. Its crumb row begins two cells further along, which is the room a
control's focus marker is written in — a visible change to a screen a
person accepted, listed for their visual review rather than taken as
reviewed.
Two things the ordinary root was not saying, and the prose that said so.

The Sidekick's pane showed no work. `workspaceOf` returned an empty list
whatever the execution held, so a reply that prepared an entry, submitted
it and was answered about it was followed on screen by nothing — while
its own records carried the chat, the response, the slot it reserved, the
entry key it named and the exact source it reserved that slot with.

Those coordinates are the association now, matched whole. An entry is a
reply's work when this reading holds one at that slot, under that key,
with that source. Any one of them alone would hand a reply somebody
else's entry: the slot alone follows admission order, so a reservation
nobody honoured takes whatever landed there next; the key alone survives
a prefix that admitted something different under the same ordinal name;
and a source is not an identity at all. So a refused Submit, an entry
outside the selected prefix and a startup that never admitted each
produce no row, which is the truthful answer in all three. What the row
then says — running, completed, failed, interrupted, and whatever the
entry printed — is read from the model where it is drawn, never from a
reply's wording.

The Transcript's selector measured a name in code units. That gets its
width wrong in both directions and cuts it in the wrong places: twenty
five `界` declared twenty-nine cells and needed fifty-four, so the engine
drew none of it; twenty-five combining accents declared more cells than
they used and published a target reaching past the column's own divider;
and a joined emoji came back as two glyphs where its author wrote one.
`fitCells` asks the engine how wide the text is and `Intl.Segmenter`
where it may be cut, and hands back the label and its cells as one
answer. The frame fits it once, before it decides whether that header
wraps, and states that one answer in every pass after it — so the row
that is measured and the row that is drawn are the same row, the mark
that says the control opens survives whatever the name costs, and the
target stays whole and inside its column.

And the two runtime paragraphs that still described the screen before
this one: the root does supply the workspace, and what it draws is the
staged composition rather than a sidebar and a seven-row footer. The
pending visual status is unchanged, and neither Follow nor QUEUE is
offered or drawn.
Two ways the screen could still say something that was not so.

A refused reservation was credited with a stranger's entry. The slot,
the key and the source identify an entry, and all three can be true of
an entry that reply never admitted: measured, a Submit refused in
preflight — the draft moved while its submission was being prepared —
left its reservation retained, and when the exact reserved source was
then typed in and run by hand at the same slot, the reply was shown that
person's entry and its output as its own work.

So the control's own outcome is asked first. A Submit observes its entry
once it has one to observe, and what that entry came to is carried beside
the outcome's status; a submission refused before any entry started
observes nothing, and a reopening that reconciles a reservation nobody
answered writes the entry it found or nothing where it found the slot was
never taken. That observation is what `coldWorkspaceEntry` already reads
to place a resumed workspace entry, and it is what the association reads
now. It is not an `ok` flag and not the error's words: an entry that ran
and failed was still admitted by the control that submitted it, and keeps
its row. A Submit with no outcome at all is the other state — this
process is still inside it — and keeps the reserved binding it had.

And the selector cut a name before the fitter could see it. `headline`
elides at fifty-nine UTF-16 units, which is the wrong unit in the wrong
place: a key that is one grapheme — an `e` under a hundred combining
accents — reached the fitter already missing forty-two of them, so the
engine measured an honest width for a name this product had altered. The
first line is still the first line; nothing shortens it on the way. What
gives up columns is the fitting, at grapheme boundaries, against cells
the engine measured — which is where that decision already lived.
Seven recovery and lifecycle journeys were reaching for controls and
rows this screen no longer draws where they looked.

Both Stops. The entry draft wears one while its entry runs and the
Sidekick wears its own, and `[stop]` found anywhere on the screen is the
leftmost — the draft's. So M6 stopped nothing it meant to and timed out
waiting for a Send, and PV3 stopped the person's entry rather than the
generation it was about, which is what left a Promise pending after the
case had given up. Both are pressed in the Sidekick's own column now.
That column is asked of the size rather than found by the composer,
because a composer is one thing it holds and not always the thing on
top: the Sidekick's question opens a drawer over that pane, and its Stop
is then a control of the drawer. Measured, with both on screen, the
draft's was at column 94 and the Sidekick's at 105.

Rest is read there too. A Send found anywhere says nothing about whether
a generation finished, because the draft has one beside its field
whatever the Sidekick is doing.

The Sidekick's question announces itself with the control that opens it.
The prose naming the agent belongs to the screen this one replaced, and
the three MQ tiers waited for it before reaching a single one of their
own assertions.

And settlement is the rail's. `[ok]` and `1. [ok] entry-1` are catalog
rows, and the catalog is a drawer somebody opens; the rail marks every
entry this file holds with the state it reached, whether or not one is
open. Same boundary, read where this screen puts it.

Readers only: no production file is touched, and every functional
assertion each tier was making is the one it still makes.
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

PR #1004: Give the REPL a responsive workspace and independent Stops

42 files, +18724 / -1051

Scope

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

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

🟡 42 files changed. Are all changes related?

Structural

Oxlint structural signals:

  • no-unused-vars ×27: packages/cli/tests/support/pty.ts, packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts, packages/cli/tests/fixtures/repl/workspace-checkpoint.ts (+4)
  • no-unnecessary-type-arguments ×2: packages/cli/src/repl/session.ts
  • no-empty-function ×1: packages/test-support/launch.ts
  • no-redundant-type-constituents ×1: packages/test-support/launch.ts
  • no-unnecessary-type-assertion ×1: packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts
  • no-unnecessary-boolean-literal-compare ×1: packages/cli/src/repl/application.ts

Slop

✅ Slop indicators look low.

Static Analysis

Oxlint: 225 diagnostics across 16 files (17 rules)
Density: 0.012 violations/added-line

no-shadow (111): packages/cli/src/repl/fitting.ts, packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts, packages/cli/tests/fixtures/repl/issue-883/mounted-program.ts (+6)
no-unused-vars (27): packages/cli/tests/support/pty.ts, packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts, packages/cli/tests/fixtures/repl/workspace-checkpoint.ts (+4)
no-floating-promises (25): packages/test-support/launch.ts, packages/cli/src/repl/session.ts, packages/cli/tests/fixtures/repl/issue-883/mounted-program.ts (+2)
consistent-function-scoping (14): packages/cli/tests/support/pty.ts, packages/cli/tests/fixtures/repl/presentation.ts, packages/cli/tests/fixtures/repl/issue-883/mounted-program.ts (+4)
no-array-sort (10): packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts, packages/cli/tests/fixtures/repl/presentation.ts, packages/cli/tests/support/repl-files-journey.ts (+3)
unbound-method (8): packages/cli/tests/fixtures/repl/issue-883/mounted-program.ts, packages/cli/src/repl/session.ts, packages/cli/src/repl/program.ts (+1)
no-control-regex (6): packages/cli/tests/support/pty.ts, packages/cli/tests/fixtures/repl/presentation.ts, packages/cli/tests/fixtures/repl/issue-883/mounted-program.ts
consistent-return (6): packages/cli/src/repl/presentation-style.ts, packages/cli/src/repl/application.ts, packages/cli/src/repl/program.ts
no-unsafe-type-assertion (4): packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts
no-duplicate-type-constituents (4): packages/cli/src/repl/application.ts
no-useless-default-assignment (3): packages/cli/src/repl/application.ts, packages/cli/src/repl/program.ts
no-unnecessary-type-arguments (2): packages/cli/src/repl/session.ts
no-empty-function (1): packages/test-support/launch.ts
no-redundant-type-constituents (1): packages/test-support/launch.ts
no-unnecessary-template-expression (1): packages/cli/tests/support/pty.ts
no-unnecessary-type-assertion (1): packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts
no-unnecessary-boolean-literal-compare (1): packages/cli/src/repl/application.ts

Correctness

FILE: packages/cli/src/repl/fitting.ts, packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts, packages/cli/tests/fixtures/repl/issue-883/mounted-program.ts
PATTERN: no-unused-vars (6+ instances)
CONCERN: Unreferenced variables declared but not used in the diff
QUESTION: Are these variables required for future expansion or are they dead code?

FILE: packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts, packages/cli/tests/fixtures/repl/presentation.ts, packages/cli/tests/support/repl-files-journey.ts
PATTERN: no-array-sort (3+ instances)
CONCERN: Non-stable sort operations without custom comparators
QUESTION: Are these sorts intended to be stable or are they accidental?

FILE: packages/cli/tests/fixtures/repl/issue-883/mounted-program.ts, packages/cli/src/repl/session.ts, packages/cli/src/repl/program.ts
PATTERN: unbound-method (3+ instances)
CONCERN: Methods bound to this without explicit context
QUESTION: Are these methods intended to be used as callbacks without binding?

FILE: packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts
PATTERN: no-unsafe-type-assertion
CONCERN: Type assertion without validation
QUESTION: Is the asserted type guaranteed by runtime conditions?

FILE: packages/cli/src/repl/application.ts
PATTERN: no-useless-default-assignment
CONCERN: Redundant default assignments to variables
QUESTION: Are these assignments necessary for clarity or are they noise?

FILE: packages/cli/src/repl/application.ts
PATTERN: no-duplicate-type-constituents
CONCERN: Duplicate type definitions in a single file
QUESTION: Are these types intended to be shared or are they redundant?

FILE: packages/cli/src/repl/program.ts
PATTERN: no-unnecessary-type-arguments
CONCERN: Explicit type arguments where inference is possible
QUESTION: Are these type arguments improving readability or adding noise?

FILE: packages/cli/tests/support/pty.ts
PATTERN: no-empty-function
CONCERN: Empty function bodies with no implementation
QUESTION: Are these stubs for future implementation or placeholders?

FILE: packages/cli/tests/support/pty.ts
PATTERN: no-unnecessary-template-expression
CONCERN: Template literals used without dynamic content
QUESTION: Are these templates intended to be static or dynamic?

FILE: packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts
PATTERN: no-unnecessary-type-assertion
CONCERN: Type assertion without runtime validation
QUESTION: Is the asserted type guaranteed by the code's logic?

FILE: packages/cli/src/repl/application.ts
PATTERN: no-unnecessary-boolean-literal-compare
CONCERN: Comparisons against boolean literals
QUESTION: Are these comparisons simplifiable to direct boolean usage?

taras added 8 commits October 28, 2026 07:08
…there

The CI failures of this head, as far as they could be carried.

Five of them were the product. An entry task halted for a named failure settled
with the general fact that it reached no outcome of its own, so a post-admission
correlation failure arrived at a submission that already had an answer: the
failure is named before the halt now, and the fallback answers with it.

The Configure drawer is the Sidekick's own pane, thirty cells of interior at a
medium frame, and a sentence longer than that was clipped at the last word that
fit with the rest of it dropped — "the Sidekick is already working on a message.
One conversation takes one turn at a time." became "the Sidekick is already". Its
notices are broken into the rows they need.

The staged chat said a turn was unfinished and offered nothing to do about it:
the ordinary pane offers Retry on exactly the condition this one left out. And
the staged Transcript announced a pending permission as an inert row, on a screen
that has no second surface to answer it from — so it is a control, selecting it
takes the surface its drawer belongs to, and the directions name the act rather
than a surface this frame does not draw.

A published attachment name is identified by more than its device and inode. An
inode number is reused, and on ext4 an owner's exit removed a replacement it had
never published — the file's creation time is part of its identity now.

The suites that reach these screens are reconciled to them: the draft read as the
block it is, the History rail read for what an entry came to, the Transcript's
selector and its attribution marks read as they are spelled, and the catalog
opened before a row of it is clicked.

Not everything this head fails is here. `repl-include-pty` reaches a footer the
PTY replay reconstructs with cells missing, `repl-agent-journey` is part way
through the same reconciliation, and one case of `repl-agent-interface` requires
a draft row and the chronology on one 72x20 frame, which the staged composition
does not draw together. Each is stated in the report beside this.
…tated

Three red files from the delivery CI, and what each of them turned out to be.

`repl-include-pty` was blamed on the harness. It is not the harness: this host
allocates its pty through tmux and reads the pane with tmux's own emulator, so
no replay is involved at all. The pane's raw bytes were captured with
`pipe-pane` and put through an independent emulator, which produced the same
holes — a History rail reading `            ✓` and directions reading `Read
for Entry 2`. In the middle of one frame the stream carries
`ESC[0G ESC[2K ESC[J`: a progress-line clear that erases the cursor's row and
every row beneath it. This renderer writes diffs. It had already written those
rows, nothing told it they had gone, and it did not write them again.

That sequence appears four times at the head of every such stream, before the
command enters the alternate screen, and nowhere in this repository's sources.
It is the runtime's own progress writer on the terminal the command owns. The
Deno launcher is quiet now, which stops it and says nothing the command itself
would have said. The intermittent `repl-repositories-pty` failure is most
likely the same thing arriving at a less convenient moment; it is recorded as
issue #1007 with both outputs, and a green repeat does not close it.

The rest of that file reaches the catalog where the catalog now is — a drawer
over the reading, opened by `Entries`, with an entry's scopes as its rows — and
the Bindings pane by the name the screen gives it.

U6's draft repaint keeps the surface it was always proved on: this suite builds
its views without staging a workspace, so the composition it draws is the one
that keeps the Draft in the footer at every surface, and the Draft it asked for
is there. What was obsolete was the oracle, not the place. The two drafts now
differ by a thirty-cell tail rather than by one digit of a hidden-line count, so
the newest row really shrinks and the fresh-versus-diff comparison is about
erasure; the row's whole allocation is read from the committed map and compared
cell for cell including its padding, rather than inferred from the length of a
padded label; and the turn control it ends on is proved to be the same turn
under a pointer and under the keyboard, not merely the same kind of act.

`repl-agent-journey` is partly reconciled: its conversations are behind the
Transcript's own selector now, and which one a reading is filtered to is what
that selector says rather than a marked row of a column this composition does
not draw. Four of its eight pass, from three. The rest are named in the report.
…keeps it

The last four agent-journey failures, and the one of them that was the product.

A turn's state and the reason it ended are two different answers. The ordinary
list has always said both — `completed, recorded` beside the prompt, and
`stopped: end_turn` under it. The staged list said only the first, so the
provider's own word for what happened to a turn was on no screen at all. It is
a row of the compact turn now, in the place its siblings are set in, and the
journeys that asserted it assert it again.

The rest were readers looking where the facts used to be. An entry's scopes are
rows of its catalog and the catalog is a drawer, so reaching `component Plan` or
the row a location selected begins by opening it and ends by putting it away —
and in the one place where that interaction stands between a person and the
control they had chosen, the focus is walked back through the ordinary ring and
proved to have come back, rather than the comparison being dropped.

The conversations are the Transcript selector's own choices: the control carries
whichever one a reading is filtered to, so what opens it is read off the screen
rather than spelled, and `All conversations` inside it is how a filter is
cleared. The inspection column is asked for by the body it draws and the name it
wears. And the draft is a block — a marker and its name on one row, what
somebody typed on rows beneath — so a draft is compared to what those rows hold,
including across a resize and a cold reopen, instead of to one row that no
composition draws.

`repl-agent-journey` is 8 passed, 22 steps, 0 failed. The suites the new row
reaches are green with it: 104/246 core, 55/191 Sidekick, 31/88 interface,
include-pty and execution.
The correction Architect18 asked for, applied where it belongs.

A drawer notice was broken into rows by a pure builder counting code units.
Measured at 120x30, whose Configure interior is thirty cells: a seventy
character ASCII token was given a seventy-two cell row and drawn as nothing at
all, and twelve spaced CJK words came back as one forty-eight cell row. A
builder has no cells to count with — `fitLabel` and `fitLine` beside it count
the same units, and that is fine for a label the engine will elide, and wrong
for a sentence somebody has to read.

It is fitted in the preparation pass now, at the interior the frame measured
and through the engine that knows what a grapheme costs. One answer serves the
window's row count, its admission, its offsets and the rows finally described,
because a pure builder consuming a prepared answer cannot form a second opinion
about the same text. Which notices a Configure drawer says is one decision too,
read by the preparation that fits them and by the builder that draws them.

The declared indent comes out of the room and goes back on every row, so a
wrapped notice still reads as one thing set in from the drawer's edge. An
unprepared pass is the unmeasured one, which describes no width-dependent text
and leaves the notice the one line it is.

`Configure notice fitting` is the permanent root. Its long unbroken token case
is here, with the red it produced against the previous builder preserved beside
it. The wide, combining and joined half of that obligation is not: editing the
field that produces such a notice did not clear a name made of wide graphemes,
and the drawer's wide cells interleave with the pane behind them in the
recorded grid. Both are written down rather than asserted, and neither is
settled by this commit.
The two observations the last call left open, settled with evidence.

**Editing was never broken.** A probe typed into the drawer's own agent field
and pressed Backspace: `scribe` shortens a character at a time and `scr界界`
loses one 界 per press. The earlier failure was a helper of mine reading the
field wrongly, not the product.

But the reduction behind that key counted code points. `e` under two combining
marks is one thing on the screen and three of them underneath, so it took three
presses to remove one character and left two readings of it on the way; a
joined emoji came apart into the two it is joined from. It removes one grapheme
now, on the same boundary the fitter cuts rows at, so what a person deletes and
where a row is broken are one answer.

**The interleaving was the replay.** The drawer's wide rows came back as
`界g界n 界o界f` — letters from the frame underneath showing between the
glyphs. The renderer's own bytes for that exact frame were captured and put
through an independent emulator that gives a wide glyph the two columns it
owns: the row it produced is `界界 界界 界界 界界`, which is what the product
wrote. The suite's replay advanced one column for every character, so the
second cell of each wide glyph kept whatever had been there. Both replays —
`mounted-program.ts` and `presentation.ts` — advance by the character's width
now, and the cell a wide glyph also owns is the glyph's.

`Configure notice fitting` is whole: the long unbroken token, the wide,
combining and joined notice with its shorter replacement erasing every cell the
longer one held, and Backspace removing one grapheme rather than one code
point. The screens both readings produced are kept beside it.
The capability half of this delivery: two direct witnesses at the seam, and
the generic documentation that describes it without naming a CLI.

`EP30` reaches a document that is still running through `executeInstalled` and
nothing else — no session, no entry, no CLI import. The document has already
printed and is waiting on a real operation; its own installation holds a
cleanup gate, so the stop cannot answer until that teardown has been joined,
and the order it joins in is asserted rather than timed. What the journal keeps
is the protocol's existing cancelled Close: one of them, for the root, carrying
a status and nothing else. The completion is not a success, nothing is invented
to stand in for the answer the document never gave, and asking again or reading
the completion again writes no second ending.

`EP31` refuses that ending at the stream seam. The persistence failure is the
run's own by identity — the stop answers with it, the completion carries the
same object, and the journal's own refusal is its cause. The cleanup this
execution owns is still attempted and joined, and no compensating terminal is
appended in place of the one that was refused.

The architecture's *Capability-backed execution* now says what a host Stop
answers for and what it leaves alone, and the durable integration spec's entry
point says what a root control retains: the existing cancelled Close, written
when the root unwinds rather than when somebody asks. No event family, no field
and no inference that a terminal means success.
The generic half, on its own: an execution a host can stop, the durable root
control it is written through, and the documentation for both.

`HostExecution.stop()` answers for the stop and the join, never for the
document. It reaches a document still running, unwinds it, and joins every
teardown the execution owns before it answers — a stop that returned while an
owned cleanup was still running would be reporting an ending that had not
happened. What the journal keeps is the terminal representation the protocol
already has: one cancelled `Close` for the root, a status and nothing else. No
answer, outcome or successful payload is invented for work the document never
finished, and asking again writes no second ending.

A persistence failure raised while that ending is being written stays the run's
own failure by identity: the stop answers with it, the owned teardown is still
attempted and joined, and nothing compensating is appended in its place. A
backing failure is still never a workflow terminal.

`EP30` and `EP31` prove both at the seam itself — `executeInstalled` and
nothing else, with no session, entry or command in them. The existing EP29
terminal-winner, fatal-versus-ordinary and durable root cases are unchanged
beside them.

This carries no command, session or presentation code. What consumes it is a
separate delivery with this one as its ancestor.
The generic half of this work is its own delivery now, and this one consumes
it. The capability branch carries the host Stop, the durable root control, the
two direct witnesses at that seam and the documentation for both; nothing of
the command, the session or the screen is in it.

Merged rather than rebased, so the history this branch already published stays
where it is and the capability arrives as an ancestor. Every file this merge
touches it already held: the resulting tree is the one that was here before it,
byte for byte.
taras added 2 commits October 28, 2026 11:00
The witness claimed partial output and had none. Its document was a single
blocking component, and `execution.output` was never read — so everything about
the stop was proved except the thing a reader of the comment would have taken
for granted.

The document prints a prefix now, before it reaches anything that blocks, and
the row drains that channel from the moment the execution exists. It waits for
both facts — the prefix seen and the component blocked — so the stop happens at
a known point rather than a timed one. Afterwards the channel's close value is
compared to exactly what was read: a stop that threw the partial text away, or
closed the channel empty, passes every other assertion here and fails that one.
Asking again adds neither a byte of output nor a second ending.

The held cleanup gate, the non-success completion, the single status-only
cancelled root Close and EP31's refused-append identity, cause, joined cleanup
and absent compensating terminal are all unchanged. EP31 reads its error
through the Result's own inferred type instead of asserting one.

No production code is touched by this.
The prerequisite's direct-host witness now prints something before it is
stopped and reads that text back off the output channel afterwards. Merged in
so this delivery carries the same capability tests its ancestor does.

One file, and it is the capability's own: nothing of this branch's command,
session, screen or its still-unfinished test reorganization is touched.
taras added 21 commits October 28, 2026 11:42
The two failures CI found on this head, and nothing else.

**An owner's exit removed a replacement it had never published.** `same()`
identified a published attachment file by device and inode. An inode number is
reused, and ext4 on the runner did reuse one: the file this owner wrote was
unlinked, a replacement was created in its place and was handed the same
number, and the exit took it away. `ENOENT` on the read that followed, in both
the Deno and the Bun job.

The correction is the one the dependent layout branch already carries, taken
whole and unchanged: the moment a file came into existence is part of its
identity. Measured here — a creation time is reported, an untouched file keeps
it, and a file created after another was unlinked does not share it. Strictly
stricter, so it can only ever leave a name alone and never remove one this
owner did not publish. This host does not reuse inodes eagerly and so cannot
reproduce the failure itself; the preserved run logs are that evidence.

**A directory was read as a list.** `readdir` of `.acpx` was compared to a
literal pair in one order, and a runner handed the two names back the other way
round. It is sorted now, exactly as the assertion above it already was: what
this row is about is which names are there, and a directory promises nothing
about the order it enumerates them in. The membership is unchanged and nothing
is skipped, slept on or excluded.
The prerequisite now reads a directory as a set rather than a list, and names
a published file by when it came into being. This branch already carried that
second correction, so the merge brings only the first.

One file, and it is a test's: nothing of this branch's command, session,
screen or its still-unfinished test reorganization is touched.
An attachment captured dev, ino and birthtime when each object came into
being, and then kept changing those objects: the directory was chmod'd and
had a socket created inside it, the staged descriptor was written, linked and
unlinked. Every one of those moves ctime, so where birthtimeMs stands in for
a ctime the kernel does not expose, a reading taken at creation no longer
describes the file by the time cleanup judges it, and an owned file is left
behind.

Re-read each identity once this owner has finished changing the object it
names, and judge the directory before its socket child is taken out of it,
since removing a child changes the directory the question is about. A second
reading answers for the same inode or it is somebody else's file; sameNode
says so rather than publishing theirs. The first reading stays in place until
the second replaces it, so a cancellation in between still joins and adopts.

Also: give DurableRootControl back the doc that had drifted off it, and say
plainly that RootInterrupted does reach the caller on the return path. And
spell the failure precedence one way — reconcile and reconcileExpansion now
use the same fromEach the stop path already had.
The three posted line comments on PR #1008 are capability-owned; the layout
work here is unchanged by them.
Reading an identity again is only worth doing if the reading is checked, and
these were checked in the wrong place. The stage comparison took its own fresh
Stats for both sides, so it could not see a replacement at all. The published
one stored the reading first and threw second — and the finalizer joins that
acquisition, so the identity it had already stored was a foreign file's, and
cleanup unlinked a file this owner never published. Cancellation made it worse,
not better: the callback still runs, and nothing after it does.

So the check moves inside the retained callback, which is where authority is
actually installed, and it compares against an identity captured before the
refresh that the refresh never writes over. A reading of another device and
inode is refused there; the identity this owner already held stays, and the
replacement stays somebody else's file.

The directory question moves too. Closing the listener unlinks its socket —
Node's documented behaviour for a path it bound — and taking a child out of a
directory changes the directory, so asking afterwards asks about a directory
this teardown has already modified. It is asked once, before any of that, and
every listener and socket join still happens before the directory is removed.

Four cases hold a refresh's read in flight: a stage name replaced under it, a
published name replaced under it, a cancellation delivered while it is
pending, and the directory judged while its socket is still in it. The first
three remove the replacement without the fix; the fourth reads the directory
after its socket is gone.
The refresh validation and the directory ownership ordering are
capability-owned; no layout behaviour moves with them.
Asking which directory this is before anything else can change it was right.
Raising the answer's failure from there was not: a read that fails is not a
listener that closed, a peer that was let go or a name this owner knows it
published, and all three were still this teardown's to finish. The attachment
and its socket were left behind with the error on its way out.

So the failure is kept rather than raised, and the stages that never depended
on that question run. Ownership stays unconfirmed, which keeps `rmdir` off the
table — a directory this owner could not identify is not one it may remove,
and that is the direction to fail in. The error this teardown ends with is the
one it was handed, by identity. When finishing also fails, both travel: the
reading that failed and the removal that failed, neither standing in for the
other.

Two cases fail the directory's teardown read with EIO through the reader seam
already there. Without this, the first leaves the descriptor and the socket on
disk, and the second reports only the read. With it, the published name and
the socket are gone, the accepted connection and the listener are joined, the
unidentified directory is still there to be looked at, and the original error
is observable either way.

Also: the two message assertions added last time read the error by narrowing
now, not by casting it.
The retained directory-read failure and the stages that outlive it are
capability-owned; no layout behaviour moves with them.
A descriptor that will not unlink says nothing about the staged name beside
it, and the one try/catch around the whole teardown treated it as if it did:
the first stage to fail ended the rest. Cancel a publication at the staged
refresh — both names exist then, one inode under two of them — make the
parent unwritable, and only the published name was ever attempted. The staged
one stayed on disk and its failure was never reported, so what came back said
less than what happened.

Each stage is attempted on its own now and keeps what it fails with. The order
is the order they were met: the reading of the directory first when that is
what failed, then each name as it was tried. One failure still leaves as
itself, because a caller's fence may be looking for that exact object; more
than one travel together.

The early ownership decision, the joins, validate-before-adopt and the
fail-safe unconfirmed directory are where they were. Nothing is removed that
this owner did not publish, and nothing is removed because removing it would
tidy up the leftovers.

The new case holds the staged refresh, takes write permission off the parent
and cancels there. Before this it reported two failures and left the staged
file; now it reports three, the reading and both names, and both are still on
disk to be seen.
Every teardown stage attempted and every failure retained is capability-owned;
no layout behaviour moves with it.
Two of these cases replaced the attachment by unlinking it and writing a new
file at the same name. On this host that is a different file and the cases
pass. On the CI host it is not: the published name is the inode's last link by
then, Linux frees the inode at the unlink and hands its number straight to the
next file created, and the stranger arrives wearing the identity of the file it
replaced. One case waited out its five minutes because nothing refused; the
other watched the owner's cleanup delete the stranger it had adopted.

So the stranger is written under its own name while ours still holds the one
under test. Two files existing at the same moment cannot share an inode — no
allocator can do that — and the cases now say so out loud before replacing
anything. Then one rename moves it over, which never leaves the name empty.

The refusal, the absence of publication, the preserved replacement with its own
inode and bytes, the cancellation and the joined cleanup are all asserted
exactly as before. The published case also stops suspending after a publication
it does not expect, so a future regression says what is wrong instead of taking
three hundred seconds to say nothing.

This host cannot reproduce the CI failure — it never reuses an inode, measured
under all three runtimes — so a green run here does not establish the Linux
one. What it does establish is that the replacement no longer depends on which
host it runs on.
The regression fixture is capability-owned; no layout behaviour and no
production source move with it.
A device and inode read off a name is a reading of history. Once the last
link to a file goes, that inode number is handed to whoever creates the next
one — so the name this publication had just written could come back wearing
its number, pass the check, and become the authority that later unlinked
somebody else's file. Timestamps cannot settle it: one runtime reports them
to the second, and a host that has no creation time to report substitutes one
that moves.

Lifetime settles it. The publication now opens the file it creates and keeps
that handle for as long as it is making claims about it. While the handle is
open the original cannot be freed, so its number cannot be given away, and
"is this name still that file" has an answer rather than an inference. The
numbers come from the handle, as bigints, because rounding an inode number to
a double would put a second ambiguity where this removes the first. A name's
own reading answers that one question and never becomes the answer.

The handle is taken even when the caller is cancelled between opening and
owning, identified from itself rather than from a pathname when cancellation
got in first, and let go of last — after every name decision, as one more
stage whose failure joins the others in its turn rather than replacing them.

Sockets, directories and unpinned names keep the predicate they had. The five
cases describe a recycled number only where one could be recycled: the live
original is checked, not assumed, and a closed one is what the old lifetime
hands them.
The pinned descriptor lifetime and its witnesses are capability-owned; no
layout behaviour moves with them.
Pinned at 70e3315, the squash merge of #1008.
This branch already carries that reviewed content, so the integration changes
no file: it records the accepted base, and the tree is the one it had.
The appearance of this product was asserted in pieces: a colour here, a
divider there, a column share somewhere else, each inside a case that was
really about something else. This adds the one place that answers the
question "what does it look like" — three records, one per accepted profile,
each holding every row of text and every style run underneath it, blank cells
included.

They are read from a recording. The fixture's own execution was run once and
kept as events, so a profile case replays a journal instead of executing a
document to find out what colour a row is. The records say which input they
describe, which focus condition, and which reviewed sources they were
transcribed from: the accepted attempt-4 literals for the wide chrome, the
corrected-size acceptance for the other two geometries, the one-list
Transcript and breadcrumb amendments, and the shipping captures at 0c37c83.
Where this input shows something those captures could not — a running entry,
its Stop, the source and the nested Checklist — the difference is written down
as an input-content difference rather than quietly adopted.

Beside them is the ledger for what leaves active input later: the shared queue
and Follow presentation, each row carrying the case it came from, that case's
a410 blob and line range, the boundary holding its obligation today, and what
has to exist before it comes back. Nothing is skipped and nothing is excluded;
the ledger is data a person reads, not a test.

No production file moves. The retirements the obligation map pairs with these
records are not in this commit.
…at is not offered

Three kinds of change, in the order the ruling put them.

The role tests say what they are. TE4, PW1 and P2 compose with viewFor and no
workspace: the production renderer draws them, but the screen is the kernel's,
not the one a person is offered. They now say so in their names and their
prose, and every assertion in them stays — the role fills, edges, floors and
dividers under both tabs, the three routed outlets, the literal column shares
including the odd 161 and both pane bodies, the footer allocation and the two
focus states. None of it was duplicated by the appearance records: those hold
one input with one tab selected.

The measurement obligation moved to a window this build has. An overflowing
Entries catalog is now watched through the real preparation: the measurement
its admission came from holds no rows of its own, both window controls and the
drawer's chrome around it, a rectangle equal to the one the finished frame
places, and a row the first frame hides reached with a whole target.

Only then did the shared queue and Follow leave. The staged workspace keeps its
work rows and its conversation — the four outcomes, the work identities and the
waiting question are read from them, on the composition this build actually
draws — and loses the queued rows, the queue tray and the Follow flag it had no
offer behind. The ledger now names the case that holds each deferred row's
obligation today and the command result that selected it.

A1 is retired into its receivers: what the screen looks like is the appearance
records, where its regions and targets are is A2 and admission, and which
reading it is of is A6 and the mounted-focus cases. The capture it pinned is
still in the history.
The appearance input was captured without the compiler installed. Every eval
in it closed `err` with "compiler not installed", the first entry settled
nothing, its bindings were empty and the nested Checklist was never imported —
so the three records were a careful, digest-consistent description of a broken
screen. The journal is recorded again with the compiler in place: a first entry
that completes, a second still running, and the nested child the second one
admits. The old journal and the records drawn from it stay in the history.

The fixture reads it the way the product does. Every line goes through
parseDurableEvent instead of JSON.parse with a type written over it, and a
record is checked field by field — profile, size, row count, every run's
numbers, the digest and the authority — so a record that lost its shape says so
about itself. Each record names the journal it was written against and each
case verifies that digest before it compares a single row.

The expectations are justified against named authorities rather than against
the candidate that produced them. Every row that plays a role the reviewed
captures also play is compared to that capture's own row, run for run, and the
nine that differ each carry the reason read off the two rows side by side —
the live head against a settled one, [stop] against [send], this session
against that one. At 160 the accepted attempt-4 probes are compared cell by
cell: twenty match exactly and four fall in the right pane's content, which the
two readings do not share. The hashes are recorded with them.

Also: the lint receipt that failed was my command, not the code. The repository
lints with five ignore patterns for its vendored trees; without them oxlint
reads 1719 vendor findings. Run as the repository runs it, one finding was
mine — a banned word in this fixture's prose — and it is fixed.
Three repairs, each to the way the appearance evidence is established rather
than to what it says.

A record is parsed now, not asserted. The six casts are gone: a schema says
what a record is and the record is read through it, so a null input, an
authority holding a number, a size missing its columns or a run missing its
width is refused by name instead of being handed on with a type written over
it. Nine of those malformed shapes are a case of their own, beside the one that
reads the file on disk without complaint.

The comparison kept the inks and threw away the coordinates. A row whose runs
shifted one cell compared equal to the reviewed capture, which is the whole
question a geometry record exists to answer. Every word the capture also draws
at the same cell is now compared there, run position and width included --
which is what pins the fixed header, the pane dividers, the Draft label, the
guidance words and the surfaces behind the blanks. Nine ink differences and
seventeen blank strips survive that, each named with the cause read off the two
rows; every other word is attributed to the input that supplied it, and every
ink it is drawn in is either the capture's or one this reading has a named
occasion for.

And the tool wrote as it went. It prepares the journal, the three records and
the map, refuses on the first thing it cannot account for, and writes only
then. A receipt injects a recolouring nothing explains: it exits nonzero and
every digest on disk is the one it started with. The old tool and its evidence
stay where they are.
The witness that proves a broken record is refused was seeded with raw
JSON.parse output, so its own callbacks had to be told what a style line is
and a run had to be asserted into an object before it could be spread. Reading
the seed through the reader settles both: the nine counterexamples are built
from a record, the callbacks know what they are iterating, and the spread that
drops a run's width needs nothing written over it.

The nine malformed shapes and the sound-record control are the ones they were,
and so is everything they are built from — the journal, the three records and
the reader are the same bytes they were at the last head.
The Node shard saw the preview-to-reply journey read a published chat with no
response in it, having just watched the screen say the Sidekick's words. Both
are true at once, and the test was reading the second after waiting for the
first.

Caught locally — once in twenty single-test runs, then again with the state
printed — the file at that moment ends in the control the evaluation had just
taken: owner, ready, conversation, …, control, control-outcome, control. No
response, no feedback. The screen beside it says "[S] Half a thought." and
"[U] think · evaluating the reply" and "[S] working", with Entry 1 already
complete. That is the product doing what it says: the words are shown as soon
as the Sidekick has said them, and the reply carrying an entry is published
once that entry has been evaluated. The two moments are not the same moment.

So the row waits for the fact it goes on to read. `awaitedFact` is `awaited`
for something only an operation can see — the file, here — with the same
deadline and the same loud failure. Nothing is relaxed: one response, one
reply row, no preview row and no "(preview)" on the screen are all still
required, and the screen must still have shown the reply before any of it is
read.

Forty single-test runs after this, none failed. The whole file passes under
Node, Deno and Bun, and no production file moves.

@taras taras left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review of the whole layout/Stops PR at 4bc489e (vs merge-base 70e3315)

Verdict. No blocking correctness defect found in the production diff. One measured performance regression should be fixed before this merges (the staged Draft is re-measured through the layout engine on every frame, so typing with a long draft halves the frame rate); two small efficiency/minimalism items and two low-severity consistency items are posted as inline comments. Accepted behaviour (the three profiles, the one-list Transcript with its session filter, both Stops, deferred Follow/queue, inactive TODO) is preserved by every recommendation below.

Correctness. Read the full production diff (packages/cli/src/repl/*, packages/test-support/launch.ts) and the contract/spec changes. Entry Stop is ordered as the architecture states: the exact entry's published execution handle, then the canonical stop (root asked while open, runner halted and joined), then the entry's own outcome, then the task's teardown, with the slot held by the stop until a retained terminal is confirmed from the acknowledged history; a failed append or cleanup withdraws admission. The catalog, the Transcript selector and the breadcrumb are presentation-only state and route through the existing navigate gate. The last correction (waiting for the published response rather than the drawn row) waits for the asserted fact and removes no assertion. Two consistency nits are inline (drawer control precedence under an overlapping catalog, and the catalog's scroll position on reopen).

Measured performance (same host, same quiet launch, head vs merge-base; details in the inline comments). Boundary: the renderer's terminal write (present) as logged by a wrapped terminal adapter around the production entrypoint, plus arrival at the pty master. Visible paint is not observed on this host, so no display FPS is claimed. Typing cadence 20 keystrokes/s.

interaction head base
Draft typing, short draft, 160x36 (keystroke→write done p50/p95/worst ms) 9.3/13.1/17.5 6.1/10.6/12.0
same at 120x30 / 72x20 (p50) 3.6 / 6.5 7.2 / 5.9
Draft typing after a 40-line paste, 160x36 19.3/21.5/38.1 6.0/10.8/11.6
Draft typing after a 300-line paste, 160x36 61.9/116.6/146.5, 8.4 frames/s (every other key coalesced) 12.2/14.5/18.9, 18.6 frames/s
same at 120x30 / 72x20 (p50/p95) 45.3/88.8 · 27.0/33.8 11.1/14.3 · 10.3/13.0
Sidekick composer typing, 160x36 8.7/12.5/14.3 6.9/10.5/13.3
Resize 160x36↔120x30↔72x20 (SIGWINCH→first frame, 12 resizes) 6–18 ms, 2 frames each 8–22 ms, 2 frames each
Entries catalog open / close by pointer (10×) 14–21 / 10–21 ms n/a (base: Entries/Sessions surface switch 7–15 ms)
Transcript scroll [later]/[earlier] with 30 turns; selector open/choose/All 15–20 ms; 7–22 ms new control
Entry Stop on a running bash exec sleep 30 (3×) first frame 10–20 ms; - INTERRUPTED retained and [send] back 31–67 ms new control
Sidekick Stop on a held scripted turn (3×) first frame 12–25 ms; [send] back 15–32 ms 15–21 ms; 17–28 ms
Configure drawer open / close (10×, scripted model list) 12–21 / 9–15 ms base run recorded in artifacts
Idle with the Draft focused 0 frames 0 frames

With a short draft the two trees are within run-to-run variance; frames track keystrokes one-to-one (18–20 frames/s at the 20/s cadence) in both. The regression is proportional to the draft's line count. CPU profile of the typing workload (V8 sampling over the inspector, startup excluded): head busy 5.1 s of 16.8 s vs base 2.0 s of 16.2 s; 3.4 s of head's time is inside fitPlain → cutAt → widths → renderer.measure for the Draft, where base spends 4 ms. Heap used after the workload 148.5 MB vs 119.4 MB, and 107.5 vs 105.9 MB after a forced GC: transient, no retained growth. Stops' feedback latency is UI only; the cleanup completion above is the frame in which the retained outcome was drawn.

Limitations: no emulator paint timing; the Sidekick workloads use the journey suite's scripted provider in-process with the production program and renderer; narrow-profile Sidekick/composer and tab workloads were not driven (the harness could not reach that outlet); a periodic ~3 frames/s is written after pointer activations on both trees (pre-existing, not counted as idle). No FPS budget was supplied and none is invented here.

Minimalism. The queue tray and Follow presentation ship inert (never offered by workspaceOf), and the ordinary composition stays in production code as a test-only kernel; both are accepted by the contract and are noted inline as follow-ups rather than blockers. scripts/repl-workbench.md still describes the old sidebar/seven-row-footer bands in its size notes (not in this diff; a one-line follow-up).

Comment thread packages/cli/src/repl/program.ts Outdated
const drafted =
draftLines === undefined || widths.surfaceBody < 1
? Ok(undefined)
: yield* fitPlain(renderer, view.size, draftLines, widths.surfaceBody);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

C1 (performance, actionable) — Draft re-fit on every frame. draftLinesOf(view) hands every line of the draft to fitPlain with no fit store, and draftRowsOf then keeps only the newest three rows. Chat, composer and guidance fits use chatFits/messageFits/guidanceFits; this one measures the whole draft through the engine after each keystroke.

Consequence (measured, real CLI on a pty, 20 keystrokes/s, keystroke→terminal write done): 300-line draft at 160x36 p50/p95/worst 61.9/116.6/146.5 ms and 8.4 frames/s (every other keystroke coalesced) vs merge-base 12.2/14.5/18.9 ms and 18.6 frames/s; 40-line draft 19.3 ms p50 vs 6.0 ms; short draft within variance. Profile (V8 sampling, typing workload): fitPlain inclusive 3.38 s of a 4.02 s prepareFrame, almost all under cutAt → widths → renderer.measure; base fitPlain 4 ms.

Recommendation (keeps the drawn rows identical): (1) give the Draft its own createPlainFitStore() beside messageFits, passed here and settled with the lines actually fitted, so unchanged lines are reused between keystrokes; (2) fit only the tail — walk the lines from the end and stop once at least WORKSPACE_DRAFT_ROWS - 1 rows have been produced — since draftRowsOf draws nothing earlier. Either alone removes most of the cost; both make a keystroke in a long draft cost about what it costs in a short one.

@@ -3754,13 +3985,33 @@ export function* prepareFrame(
if (!rail.ok) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

C2 (efficiency, small) — prepareRail is wasted work on the staged screen. The prepareRail call just above runs one engine widths render per frame to lay out the five-row History band, but workspacePresentation only carries the result as manifest.history; the compact row is built from compactHistoryRuns and nothing in the staged path reads the band (bandRowsOf(rail, …) is only used by the ordinary composition). Measured: 65 ms over the 16.8 s typing workload and 49 ms over the catalog workload (~0.3 ms per frame). Recommendation: skip prepareRail when stagedWorkspace(view) !== undefined (pass undefined as the rail), or move the call into the ordinary branch.

Comment thread packages/cli/src/repl/application.ts Outdated
@@ -5917,9 +6608,13 @@ function drawerFor(
// changes nothing.
configuring
? { select: "configure-close" }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

C3 (correctness, latent, low) — Drawer controls and drawer content choose the drawer in different orders. drawerContent shows the catalog first when presentedCatalog(view); drawerFor picks [earlier]/[later] and [close] in the order question → Configure → catalog. If the catalog were presented while Configure or the question form is held open, the drawer would show the Entries rows but [close] would close Configure and the scroll controls would move the other window. I could not reach that state through mounted controls (both are modal, and open-catalog is only offered outside a modal), so no user-facing defect is claimed. Recommendation: consult presentedCatalog first in drawerFor to match drawerContent, or refuse open-catalog while either is presented, so the two cannot drift.

? { ...chosen, state: Object.freeze({ ...chosen.state, catalog: false }) }
: chosen;
}
case "open-catalog": {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

C4 (consistency, low) — Catalog scroll position on reopen. open-catalog resets the entries viewport to its first row (atFirstEntry), while the comment at drawerOffsetOf says the catalog "keeps the entries viewport it has always been scrolled by, so a reader who scrolls, closes and opens again is where they left off." One of the two should change; the spec is silent on scroll ("changes no scope, no marker, no filter and neither draft"), so either is acceptable, but the comment and the reducer should agree.

*
* So the tray is TODO, which is the inactive placeholder it has always been.
*/
export function workspaceOf(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

C5 (minimalism, non-blocking) — Queue and Follow presentation ship inert. workspaceOf never supplies trays, follow or queued, so queueSequence, trayAllocation, queueTextWidth, the QUEUE_WINDOW admission, the follow and X controls and the two queueProbe: true presentation passes per frame are reachable only from fixtures that call withWorkspace directly (about 300 lines). The contract defers the queue and Follow, so this is not a scope violation; recommendation for the follow-up: move this presentation with the queue delivery (#964), or keep it in a module of its own so the per-frame path reads without it. The ordinary composition kept as a test-only kernel is the same kind of follow-up.

… without one

The staged Draft handed every line of the draft to the layout engine after
every keystroke and then drew the newest three rows of the answer. Typing into
a three-hundred-line draft cost 61.9ms a key at 160x36 against 12.2ms before
the staged composition existed, and half the frames were coalesced away.

Each line is cut on its own and every line is at least one row, so the last
three lines already hold every row the block can show: fitting them is fitting
what is drawn. They go through a store of their own as well, beside the chat's
and the composer's, so the lines nobody is typing into are measured once.

The same frame also prepared a five-row History band the staged composition
never draws, and the drawer's controls chose their window in one order while
the drawer's content chose it in another. The band is prepared for the
composition that has one; the controls are chosen in the order the content is.
@taras
taras marked this pull request as ready for review October 29, 2026 02:48
@taras
taras merged commit c2613b0 into main Oct 29, 2026
44 checks passed
@taras
taras deleted the agent/repl-layout-971-20261027 branch October 29, 2026 02:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant