✨ Slice D of #854: present Agent conversations, turns and permissions in the REPL - #861
Merged
Merged
Conversation
The Sessions surface said `(none retained)` whatever the process was doing. It now presents one chronology of Agent work: every turn this process observed and every turn the history holds, with the conversations to filter them by and the permission request a turn is waiting on. A turn is not a concatenation of two lists. Prompts running beside each other publish in whatever order their providers answer, so an earlier Prompt can still be live while a later one is already durable — appending the live turns to the retained ones would put it second. Each observed Prompt keeps a slot, taken when it was scheduled, and a slot survives publication: the same mounted node, in the same place, sourcing its facts from the record once there is one. The slot also keeps the last live facts it had, so no frame between the append and its projection shows a turn as gone. Turns this process never observed are in the prefix it replayed, and come first in the order their records state. A pending request appears inline on the turn that is waiting. Arriving opens nothing, moves nothing and claims no focus. Activating it records the request's opaque key in process-local state and opens the Sessions-only `+permission` drawer, which draws every choice the provider offered — `always` scoped to this Agent session, never to a machine — and says that closing denies while the session keeps running. Only a successful authority call closes it: the reducer decides, the program's own `perform()` calls `choose` or `dismiss` once, and focus returns to the turn that was waiting. A request that stops existing without a UI action withdraws its drawer without claiming a choice or a denial. Retained audits are read and never answered. Narrow gives its whole screen to one routed surface, so the other one is not described at all — not clipped, not placed in a region the frame does not have. A node nothing can show is a focus stop that draws nothing and a target behind nothing. `runReplProgram()` accepts the permission mode it passes to the session. Without one every REPL is `deny-all`, so no request could ever reach a person and the end-to-end row could not exist; which mode a *command* selects is still Slice E's.
Five things the Sessions surface got wrong, and one comment that had stopped being true. **A frame nobody asked for is a frame nobody draws.** The loop woke for the history, the overlay's output, a question and expansion, and not for the Agent. A document whose only activity is a Prompt — queued, then streaming, then waiting on a permission — changed nothing else, so nothing drew the frame that would have shown it, and the screen stood still until some unrelated event happened along. `runReplProgram()` now subscribes to `session.agentChanges` in the scope that outlives the spawn and drains it there: a spawned body starts a turn later, and a turn is long enough to miss the first change. **A screen you cannot leave is not one a route may put you on.** Narrow mounts one outlet, and both surface controls used to live inside the outlets they select — so the control that goes to Sessions was mounted only on Sessions. The two are now described at every size, above whichever outlet is routed. What the route did not select is still absent everywhere: `mounted()`, the frame, the target map, `nodeOf()` and a pointer sweep. **Two readings were described whole and clipped by layout.** Every conversation, turn, fact, audit and request went into the description, and so did every choice a provider offered — `PermissionRequest.options` is unbounded. Whatever did not fit was described, focusable and pointable with nowhere to be drawn. Each now moves through a window sized from the region that will place it, with its own earlier and later controls outside the thing they move. Both offsets are process-local: they are in no route, no model, no location and no Journal, they are clamped where they are read, and a filter starts its reading again at the first row because row forty of everything is not row forty of one conversation. **A key is a reconciliation identity, not a product fact.** Targetability was decided by how a key was spelled — anything ending `:text`, `:stop`, `:failed` or `:whose` was read as one of a turn's own facts. Keys carry provider session keys and field names, so a conversation named `text` and a field named `stop` lost their pointer to a rule about suffixes. What decides it now is what the row is: a control answers Enter, so a pointer on it asks for the same thing, and a line has nothing to activate. **And the comment above `availabilityOf()` said twice what it says once**, the first copy explaining that no Agent runtime runs in the REPL — which it now does. `architecture.md` and `specs/repl-spec.md` say all of this in the present tense.
taras
force-pushed
the
agent/issue-854-sessions
branch
2 times, most recently
from
September 30, 2026 22:08
28f77af to
ebbcc7c
Compare
Two ways a viewport still lied about what a person could reach. **A long draft took both surface controls off the screen.** The location is the draft's home until an entry exists, so a thousand-character draft is a thousand-character URL — fifteen rows at 72 columns, in a region that has thirteen. Everything under it was described, mounted and focusable with nowhere to be drawn: not the way to Sessions, not the way to Entries, not one row of the outlet the route had selected. Reserving a minimum row for the reading could not help, because the fixed content had already used the whole region. A narrow frame now draws at most three rows of the location and says how many characters it is not showing. A prefix of a location is no use to somebody copying it — which is why it is still drawn whole wherever there is room, and why the command still prints all of it — but a person with a URL and no controls cannot do anything at all, including get to a window where the whole thing would fit. **The first press after a resize could move nothing.** A frame draws the clamped offset, while both reducers added the delta to the stored one. Growing `72x20` to `72x21` makes each window hold one row more, so the last window starts one row earlier: the screen was already showing that position while the stored number was past it, and pressing `[^ earlier]` only normalized state nobody could see. Both windows now move from the position their frame is drawing, through the one clamp that decides where a window is.
taras
force-pushed
the
agent/issue-854-sessions
branch
from
September 30, 2026 22:15
ebbcc7c to
b355ea1
Compare
taras
marked this pull request as ready for review
September 30, 2026 22:36
4 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #854. Slice D of five.
Stacked on four merged foundations, all already on
main:<All>/<Spawn>concurrent document work.Why
The REPL could run a document, follow its Agent turns and ask an
<Elicit>, butthe Sessions surface said
(none retained)whatever the process was doing. Theturns this process was watching, the conversations they belonged to and the
permission a turn was blocked on were all real and none of them were readable.
What changes
Before:
(none retained). A pending permission request could be waitingwith nothing in the interface to answer it.
After:
observed and every turn the history holds — with the conversations to filter
them by, and answers the permission request a turn is waiting on.
How it works
A reading is not a concatenation of two lists. Prompts running beside each
other publish in whatever order their providers answer, so an earlier Prompt can
still be live while a later one is already durable; appending the live turns to
the retained ones would put it second. Each observed Prompt takes a slot when
it is scheduled, and the slot survives publication: the same mounted node, in
the same place, sourcing its facts from the record once there is one. The slot
also keeps the last live facts it had, so no frame between the append and its
projection shows a turn as gone. Turns this process never observed are in the
prefix it replayed and come first, in the order their records state.
Slots are process-local. The live key and the order never enter
ReplModel, aroute, Journal bytes, an error string or a public Core API.
Filtering is a semantic action resolved at the root. Selecting a
conversation carries only its non-empty provider session key and changes only
route.session; clearing to All is its own action and removes only that member.Neither touches the surface, scope path, history marker, inspection flag, draft,
drawer stack or focus, and background queued/start/delta/permission/terminal
changes never select or clear a filter. A queued turn has no conversation
selector until the provider's
startedevent supplies an actual session key —it is never inferred from the authored Session name, prompt text, agent or
position.
A pending request appears inline on the turn that is waiting. Arriving opens
nothing, moves nothing and claims no focus. Activating it records the request's
opaque key in process-local state and opens the Sessions-only
+permissiondrawer, which draws every choice the provider offered —
alwaysscoped to thisAgent session, never to a machine — and says that closing denies while the
session keeps running. The key is never encoded in the route.
Only a successful authority call closes the drawer: the reducer decides, the
program's own
perform()callschooseordismissexactly once, and focusreturns to the turn that was waiting, spent once so later traversal is not
pulled back. An unknown, stale, already-settled or unoffered action settles
nothing and calls nobody. A request that stops existing without a UI action
withdraws its drawer without inventing a choice or a denial. Retained audits are
read and never answered.
Narrow gives its whole screen to one routed surface, so the other outlet is
not described at all — not clipped, and not placed in a region the frame does
not have. A node nothing can show would be a focus stop that draws nothing and a
target behind nothing. What is not part of either outlet is the pair of
surface controls: they are described at every size, above whichever outlet is
routed, because a screen a person cannot leave is not one this route may put
them on.
Both readings are windowed, not clipped. Every conversation, turn, fact,
audit and request is built in order and then moved through a window sized from
the region that will place it; the permission drawer does the same over its own
ordered content, because
PermissionRequest.optionsis the provider's andnothing bounds it. Their earlier and later controls — and
[close], and thesurface controls — stay outside the thing they move. Only what a window holds is
described, so only that is mounted, focusable, drawn and pointable. Both offsets
are process-local, clamped where they are read, and absent from the route, the
model, the location and the Journal; a filter starts its reading again at the
first row, because row forty of everything is not row forty of one conversation.
A window moves from the position its frame is drawing rather than from the
number stored, so the first press after a resize moves the screen instead of
normalizing state nobody can see.
The canonical location is bounded where it shares a region with the
controls. It is drawn whole wherever there is room and the command still
prints all of it, but a narrow frame draws at most three rows of it and says how
many characters it is not showing: the draft lives in the location until an
entry exists, and a thousand-character draft is fifteen rows in a region that
has thirteen — which left both surface controls and the whole routed outlet
mounted, focusable and drawn nowhere.
The loop wakes for Agent work.
runReplProgram()subscribes tosession.agentChangesin the scope that outlives the spawn and drains it there— a spawned body starts a turn later, and a turn is long enough to miss the
first change. A document whose only activity is a Prompt changes nothing else,
so without this its queued, streaming and waiting-for-permission states sat
unseen until some unrelated event happened to draw a frame.
A pointer follows what a row is, not how its key is spelled. Keys carry
provider session keys and field names, so a rule about
:text,:stop,:failedand:whosesuffixes took the pointer away from a conversation namedtextand a field namedstop. A control answers Enter, so a pointer on itasks for exactly what Enter there asks for; a line has nothing to activate.
runReplProgram()accepts the permission mode it passes to the session. Withoutone every REPL is
deny-all, so no request could reach a person and theend-to-end row could not exist. Which mode a command selects is Slice E's.
Review guide
Start with:
packages/cli/tests/repl-agent-interface.test.tsThen review:
packages/cli/src/repl/agent.ts— the privateReplAgentSlotseam: what itkeeps, and that it keeps it process-locally.
packages/cli/src/repl/program.ts— the Agent subscription, acquired beforethe spawn that drains it.
packages/cli/src/repl/application.ts— the Sessions reading, the filter andpermission actions, and the narrow composition.
packages/cli/src/repl/program.ts— the only place session authority isperformed.
packages/cli/src/repl/components/{actions,rows}.ts— the widened closedaction vocabulary.
Look carefully at:
consume(): one slot, one mounted identity,no duplicate and no blank frame;
authority, session, provider callback or scope — only scalar keys and IDs.
What must stay true
Chronology is slot order, not activity order — enforced by the slot taken
at observation and checked by
U1: an earlier live turn stays before a later recorded one, and survives its own record.Presentation holds no authority — enforced by components returning actions
and
program.tsalone receivingReplSession, checked byU2: an unknown request or an unoffered option changes nothing and calls nobody.A retained audit is inert — checked by
U2: a recorded permission audit is read, never answered.An inactive outlet in a narrow frame does not exist, and the way out of the
active one does — checked by
U3: narrow Sessions mounts its own outlet and nothing of the other panes,U3: narrow REPL mounts no Sessions rowandU5: a narrow frame is left in both directions, by key and by pointer.A window bounds what is offered — checked by
U6andU7, which place arequest and every provider choice beyond the first window and reach each of
them by scrolling.
A frame is drawn when Agent work moves — checked by
U4: queued, streaming and a waiting request each repaint on their own, withnothing else able to wake the loop.
Every control this frame describes is one it places, and the outlet stays
usable — checked by
U6: a draft long enough to fill the region still leaves every control placed,which takes a placed
sessions:turn:*control, resolves a pointer againstthat exact frame to the same action Enter asks for, and then walks the window
to the recorded turn, whose
select-markeris the one action neither surfaceselector can ask for — all while the canonical location is unchanged.
A window moves from where it is drawn — checked by
U6/U7: the first press after a resize moves the window, not the stored number.The omission summary is a complete row — checked by
U6: a shrinking omission summary repaints cleanly, and is a complete row,which renders a four-digit summary and then a three-digit one through one
renderer and reads the screen back, then asserts the described row's own
width. Every other location row is padded by
chunked(); this is the one rowwhose length changes, so it is padded where it is described.
Two separate measured facts, and the row keeps them apart. The renderer
repaints it cleanly either way: it fills a placed cell to its bounds, and
under the unpadded control the second paint writes
ESC[3;3H939 more characters, in a wider window·— the trailing spacecovering the longer row exactly. So the rendered screen says what this
renderer does; the width assertion is what discriminates, and says the
application owns the full-width contract itself rather than inheriting it
from whatever draws the row.
How to verify it
deno task test packages/cli/tests/repl-agent-interface.test.ts \ packages/cli/tests/repl-composition.test.ts \ packages/cli/tests/repl-terminal.test.ts \ packages/cli/tests/repl-route.test.ts \ packages/cli/tests/repl-forms.test.tsrepl-agent-interface.test.tsdrives controlled realReplSession/Journalstate and the real Freedom tree — not a shaped object or a screenshot:
<All>/<Spawn>, ordering conversationsby earliest turn, and that background start/delta/permission/terminal changes
preserve the whole route, the filter and the exact focused node. It fails if
selection or order is taken from latest activity, or if a published turn
changes its mounted key.
actions, that arrival opens nothing, that one choice settles once and closes,
that Escape denies exactly once through
dismiss, and that a stale request orunoffered option calls no authority. Two of its rows run the real program end
to end. It fails if the drawer auto-opens or a pointer is routed at a target
instead of through mounted dispatch.
readings alone, that narrow mounts only its routed outlet, that the drawer
traps focus while keeping the one History node reachable inside it, and that
every accepted frame carries exactly one canonical location. It fails if
inactive panes stay mounted in narrow composition.
transition at a time, with no input and nothing else moving. It fails if the
loop does not wake for the Agent.
resolved from the drawn frame, in both directions, and that a request arriving
on the other surface opens nothing and is still reachable from this one.
window become placed and pointer-targetable by scrolling, that the window
controls and
[close]never scroll away, that off-window rows are in no targetmap, and that choosing or dismissing calls the authority exactly once.
Enter, and that a fact stays a fact wherever it is drawn.
Scope
Included
+permissiondrawer and the one path that answers it.architecture.mdandspecs/repl-spec.md, in the present tense.test-weights.jsonat the delivery head.Intentionally unchanged
route.ts,model.ts, the durable Prompt record, the Agent providerstack, the Elicit parser, the CLI grammar, runtime entrypoints and packaged
Plan.md.New abstractions
ReplAgentSlotexists because a reading that concatenated live turns withretained ones cannot say which durable row replaced which live one, and would
reorder concurrent Prompts by completion. Its only consumer is the Sessions
reading; it is private to the REPL and reaches no route, model or Journal.
Generated or mechanical changes
test-weights.jsonwill come from the Measure test weights workflow and iscommitted unchanged with the provenance that run supplied. It also repairs
Slice C's omitted measurement: it is the first measurement of a corpus
containing both
repl-forms.test.tsandrepl-agent-interface.test.ts.Risks and limitations
order and the last live facts per observed Prompt; a reader that needs more
than that from a published turn should read the record.
design: nothing supplies a conversation to file it under.
rather than scrolling to it. Restoration after a permission is answered lands
on the owning turn when that turn is in the window, which is where the person
answering it was.
Scope confirmation