Flow: a dialog — asked in the host, answered once, settled for every follower
An extension’s ctx.ui.select / confirm / input / editor blocks its run until a human answers.
In-process there is no wire for that wait: the host is the UI (extension-ui-context.ts, header),
so the question is parked in the host’s pending map and rides to every follower as a blocking
extension-ui-request observation (#27, #112). The answer is one unary POST /api/threads/:id/dialog,
checked against the question it names. Every way the wait can end crosses one finish, which emits
dialog-settled with its outcome — how a tab that did not answer learns the card is stale — and a
follow opened under an open question finds it in the snapshot’s openDialogs (#114).
The sequence
Section titled “The sequence”sequenceDiagram
participant X as extension
participant H as host (extension-ui-context.ts · map-events.ts)
participant F as feed
participant R as dialog route (server/follow.ts)
participant B as browser (custom-event-router.ts · chat-screen.tsx)
X->>H: ctx.ui.confirm(title, message, { signal?, timeout? })
H->>H: awaitDialog — mint id, park { resolve, request } in pendingDialogs
H-->>F: observation extension-ui-request { id, blocking: true, dialog, origin }
F-->>B: routeUiRequest — { id, dialog } checked against EnsoOpenDialog, card rendered
Note over X,B: the thread is busy (parked) · nothing in the server or the browser holds the promise
B->>R: POST …/dialog { id, value | confirmed | cancelled: true }
R->>R: peek (404 cold) · openDialogs().find(id) (404 spent) · validateDialogAnswer unless cancel (422)
R->>H: runtime.answerDialog(answer) — then 204 to B · the response says nothing else
H->>H: pending.resolve ⇒ finish(value, 'answered'): delete entry, clear timer, unhook signal
H-->>F: observation dialog-settled { id, outcome } — every follower drops the card for THIS id
H-->>X: the extension's promise resolves — after the settle is on the stream
Note over H: browser cancel / dispose ⇒ 'cancelled' · extension's timeout ⇒ 'timeout' · extension's signal ⇒ 'aborted'
Note over F,B: a follow opened while parked: snapshot.openDialogs lists the question, oldest first
The invariants it shows
Section titled “The invariants it shows”- The question is parked in the host, and nowhere else.
awaitDialogmints the id, keeps{ resolve, request }in aMapkeyed by it, and emits the request on the same emitter as the session’s events, so request and settle sit in stream order. Nothing parks server-side; the run keeps going on the follow (follow.tsheader). The thread is busy (glossary.md→ parked). - An answer is checked against the question it names before the runtime sees it.
answerThreadDialogpeeks — never builds — then finds the id in the runtime’sopenDialogs(): a cold thread or a spent question is 404; a non-cancel answer the fence below refuses is 422 and the question stays open; only then isanswerDialogcalled and 204 returned. - Every path that stops the wait goes through
finish. Answer, browser cancel,dispose’scancelAllDialogs, the extension’s owntimeout, the extension’s ownsignal— each deletes the pending entry first (so a second path is a no-op), emitsextension_ui_settledwith its outcome, and then resolves the extension. The runtime has no event for a question ending. One exception: asignalalready aborted when asked resolves the cancel value with no request and no settle. - A non-answer resolves the runtime’s own cancel value, never a wrong type.
select,inputandeditorseeundefined;confirmseesfalse(CONFIRM_DISMISSED).mapResponsedegrades a wrong-shaped answer to that value. An answer naming no pending id is a logged warning. - The settle is for one id, on every follower; the snapshot’s list is complete. The browser
drops the card only when
dialog-settlednames the id it shows.openDialogs()walks the pending map in insertion order — oldest first — checking each request withisEnsoDialogSpecrather than casting; the snapshot carries that list whole, so a reconnect renders the card and an empty list clears one. - Stop stops: the run’s abort settles every pending dialog as
aborted.agent-host.ts#aborttells the runtime first — so its loop holds the abort signal when a blockedtool_callhandler returns; pi raises it synchronously, before its firstawait— then, while the runtime winds down,abortAllDialogs()resolves each pending question to its cancel value with the glossary’s outcome for exactly this,aborted, and every follower hearsdialog-settled; andrefuseDialogsUntilanswers any question asked before the stop finishes with its cancel value, showing nothing. Before 2026-09-16 the host settled nothing on abort, and the vendor’s permission prompt passes no signal of its own, so Stop during a permission question waited on the card until a human answered it (decided: if the user says stop, it’s stop). Until the #376 review the settle came only AFTER the runtime’s abort resolved — which never happens while a tool’sexecuteis blocked on a question (ask_user), so Stop waited on that card for ever.
The validation contract
Section titled “The validation contract”/** * Does this payload answer THIS dialog? * * Returns `undefined` when it does, or a human-readable refusal. Lives beside the schemas * so both surfaces enforce the same rule — in particular the select rule: pi hands the * answered `value` to the extension verbatim, so a value outside the offered options would * put a string the extension never offered into its control flow. That is an injection * seam, and it is closed HERE rather than trusted to the renderer. */export function validateDialogAnswer(spec: EnsoDialogSpec, payload: unknown): string | undefined { if (!dialogAnswerValidator.Check(payload)) { return `the payload is not a dialog answer ({ value: string }, { confirmed: boolean } or { answers: [...] })` } switch (spec.method) { case 'confirm': return 'confirmed' in payload ? undefined : `a 'confirm' dialog takes { confirmed: boolean }` case 'select': if (!('value' in payload)) return `a 'select' dialog takes { value: string }` return spec.options.includes(payload.value) ? undefined : `'${payload.value}' is not one of the offered options` case 'input': case 'editor': return 'value' in payload ? undefined : `an '${spec.method}' dialog takes { value: string }` case 'questionnaire': if (!('answers' in payload)) return `a 'questionnaire' dialog takes { answers: [...] }` return questionnaireRefusal(spec.questions, payload.answers) }}The tests that pin it
Section titled “The tests that pin it”Each marker is checked by test/docs-gate.test.ts: a renamed or deleted test with that exact title fails this page.
- Invariants 1 and 2, end to end —
follow.test.ts: 422, 404, 204, then 404 on replay; the settle frame precedes the status frame.
- Invariant 5, over the wire — one tab answers, two others get the same
dialog-settledframe.
- The fence above, every branch —
schemas.test.ts.
- Invariant 1, through the real runtime —
agent-host.test.ts.
- Invariants 3 and 4, the cancel and dispose paths.
- Invariants 3 and 5, in the host —
extension-ui-context.test.ts.
- Invariant 3,
abortedandtimeout, and the never-asked exception.
- Invariant 5, in the browser —
custom-event-router.test.ts.
- Invariant 6, at the host —
agent-host.test.ts: abort →dialog-settledwith outcomeaborted; the extension resumes with its cancel value; a late answer is reported, not applied.
- Invariant 6, the settle itself — every pending question, once, under the glossary’s word.
Where it is stated
Section titled “Where it is stated”The vocabulary and both schemas: seams/dialog.md. The host’s wait:
packages/web/src/host/extension-ui-context.ts (header, awaitDialog, answerDialog, openDialogs,
cancelAllDialogs); its binding and dispose: agent-host.ts. The route: server/follow.ts#answerThreadDialog;
the snapshot’s list: follow.ts#followThread. The settle’s mapping: map-events.ts → extension_ui_settled.
The browser: custom-event-router.ts#routeUiRequest and its dialog-settled case; follow-connection.ts#answerDialog.
Parked, dialog, answer, outcome: glossary.md → Thread states, Control.