RFD D46: Queued Replies
- Status: Draft
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-07-31
- Extends: RFD 045
Summary
A new interrupt action, [q] Queue reply, composes a message without stopping the assistant. The turn runs to completion, a countdown gives one last chance to cancel, and the queued text then starts a new turn in the same jp invocation. The queued text lives in the conversation's QUERY_MESSAGE.md draft, so a failed run leaves it recoverable with a bare jp q.
Motivation
Two interrupt actions exist for "I want to say something": [r] Reply while streaming, and [r] Stop & respond while tools run. Both stop the assistant to do it. There is no way to say "keep going, and here is what to do next."
The use case that motivates this is stepping away from the keyboard. The assistant is working through a long turn, the direction is already clear, and the user wants the next instruction queued so the run continues without them. Today that requires waiting for the turn to end and typing jp q — which means being present at exactly the moment the turn finishes.
Doing nothing keeps every mid-turn message an interruption in the literal sense: the only way to add context is to discard the rest of the response.
Design
Behavior
Both interrupt menus gain one entry:
Interrupted
[c] Continue [r] Reply (stop & respond) [q] Queue reply (finish, then respond)
[s] Stop (save & exit) [a] Abort (discard & exit)Interrupted
[c] Continue [r] Stop & respond [q] Queue reply (finish, then respond)
[s] Stop (cancel & exit) [t] Restart[q] opens the same compose surface as [r] — inline widget or external editor, following that context's existing compose_in_editor setting — and then returns to exactly what was happening before. Streaming resumes, running tools keep running. Nothing about the turn's trajectory changes.
queue_reply is the one action whose meaning is identical in both contexts. Its neighbours differ deliberately (reply stops the stream, respond cancels tools), which is why the same identifier is correct in two places where the surrounding vocabulary diverges.
The terminal stays silent while composing, as it does for [r] today; assistant output resumes after submitting. The provider stream is not polled during that window, so a long compose can lose the connection — that routes through the existing retry path (transient error → retry, or continue from the partial response) and needs no special handling.
One slot, seeded
There is one queued reply, not a list. A second [q] seeds the widget with the text already queued.
| Input | Effect |
|---|---|
| Submit, unchanged | Slot keeps the same text. |
| Submit, edited | Slot replaced. |
| Submit, buffer emptied | Slot cleared, draft removed. Back to the menu. |
Ctrl+C | Slot unchanged. Back to the menu. |
Ctrl+C means "back up a level, change nothing", exactly as it does on the reply path. Withdrawing a queued reply is the emptied-submit row: clear the buffer, press Enter. Ctrl+C is deliberately not the withdraw path — it would be the only place in the interrupt system where Ctrl+C destroys text the user typed.
The draft file is the slot
The queued text is written to the conversation's QUERY_MESSAGE.md, the same file preserve_query_message_file already manages. The in-memory slot is a cache over it. Two rules govern its lifetime:
- The file is written when a reply is queued.
- The file is deleted only when the queued reply is sent as a new turn, or when the user explicitly discards it.
Every other path — fatal provider error, [s] Stop, [a] Abort, escalation, query.loop = "never" — leaves the file alone. Nothing on an abnormal path destroys text the user typed, and manual resumption comes for free: after any failure, a bare jp q seeds from the draft.
Two existing behaviors need care here, and they are where a data-loss bug would come from:
cleanup_query_message_fileruns when a turn succeeds and would delete a queued reply. Ownership of the file's lifecycle moves to the turn loop: clean up after the loop, only when the slot is empty.A narrower guard already stands in for this:
jp queryfingerprints the draft when it composes its request (DraftRemoval::IfUnchanged) and a successful turn removes the file only while it still matches, so a draft written mid-turn through the plugin protocol'swrite_draftsurvives. The slot-based rule replaces it, and the fingerprint plumbing comes out with the same change.The file is a
QueryDocument(config preamble plus query text), whilepreserve_query_message_filewrites raw content. Writing a queued reply must preserve an existing preamble, which is the same problem RFD 093 is solving in this file.
Recovery is conditional: preserve_query_message_file only writes when user-local storage is configured, because scratch must never land in the committed workspace directory. Without a user-local store the feature still works and only crash recovery degrades — a gap today's failed-turn recovery already has.
The turn loop and the Run scope
jp query runs exactly one turn per invocation: run_turn_loop returns when the phase reaches Complete, and nothing above it loops. A queued reply needs a driver that runs one or more turns, which is the structural part of this proposal.
The turn loop goes above the phase machine, in handle_turn. That makes run_turn_loop a misnomer — it drives the phases within one turn (Idle → Streaming → Executing → Complete), not a loop over turns — so it becomes run_turn_phases and turn_loop.rs becomes turn_phases.rs. "Turn loop" then names the outer driver, matching the query.loop config key.
Each iteration rebuilds the per-turn pieces that are already constructed there or inside the phase machine (turn coordinator, tool coordinator, renderer, retry budget), so the turn boundary the state machine is built around stays intact. The queued reply becomes the next turn's ChatRequest, author-stamped for transcript attribution the way an interrupt reply is today.
run_turn_phases gains a TurnEnd { Completed, Stopped, Aborted } return. Today its caller cannot tell a natural completion from a user Stop or Abort — all three are Ok(()) — and the consumption policy needs that distinction in one place rather than scattered across two interrupt handlers.
A new turn starts only when the previous turn ended on its own. Stop, Abort, escalation, a between-phase interrupt, and fatal errors end the run with the draft intact.
A run containing several turns forces a scope that did not previously exist. PersistLevel today is:
pub enum PersistLevel { None, Turn }Turn and a hypothetical Run are indistinguishable while a run holds one turn, so no user has an expectation about the difference. This RFD makes them differ, and resolves it by renaming rather than adding:
pub enum PersistLevel { None, Run }TurnState::persisted_inquiry_responses moves to the turn-loop driver, and the two prompt labels become "remember for this run". A permission approved with [Y] in the first turn therefore holds for the queued turn, which is the point: an unattended run that stops at "are you sure?" defeats the feature.
Adding Run alongside Turn was rejected — nothing would emit it without also adding a prompt option asking users to distinguish two scopes they have never been able to distinguish.
The send countdown
Before a queued reply is sent, a countdown runs on the chrome channel:
⏳ Sending queued reply in 7s… (^C to review)spawn_line_timer drives it: delay = 0, a short interval, and a format closure rendering duration_secs - elapsed. The window is a select! over the completion sleep, the timer ticks, and an interrupt notification from its own signals.push_handler() scope, matching the shape of the streaming loop. It runs between turns, after the previous turn is flushed, so an interrupt there cannot lose committed work.
Ctrl-C during the countdown opens a third interrupt menu:
Queued reply
[n] Send now [e] Edit draft [k] Keep draft (don't send) [d] Discard drafts and c are deliberately not used: they mean Stop and Continue in the two menus that appear seconds earlier in the same flow, and rebinding them here would invert them. [d] is the only destructive choice in the whole feature.
[e] opens the compose surface seeded with the queued text, following this menu's own interrupt.loop_countdown.compose_in_editor. It cancels the countdown on the way in: a timer that fires mid-compose would send the text the user is in the middle of replacing. Saving returns to this menu with no countdown running — the user pressed Ctrl-C, so they are at the keyboard, and a blocking menu matches the two interrupt menus that came before it. Ctrl+C in the composer leaves the queued text unchanged and returns here. An emptied submit clears the slot, which leaves nothing to send: that is [d] by another route, and the invocation ends.
This is the only place a queued reply can be revised after the turn ends, which is exactly when the full response is finally visible.
Ctrl-C on this menu escalates (RFD 045): keep the draft and begin a graceful shutdown. Escalation never takes the destructive branch.
Without a tty the countdown is skipped and the reply is sent immediately, matching how spawn_waiting_indicator behaves.
Configuration
Two new values on the existing action enums:
[interrupt.streaming]
action = "queue_reply"
[interrupt.tool_call]
action = "queue_reply"What happens when a turn ends is a [query] key, because it is a property of the invocation rather than of Ctrl-C:
[query]
# When to run another turn after one ends.
#
# - `never`: end the invocation. A queued reply stays as a draft for the next
# `jp q`.
# - `if_pending`: run another turn when a reply is queued (default).
loop = "if_pending"The values answer one question — when do you loop? — so they order on a single axis, and never reads unambiguously where a key named run with a value none would not.
loop = "never" turns the feature into a mid-turn scratchpad: compose notes while the assistant works, keep them as the next query's draft, never send them automatically. A chrome line at turn end says so, otherwise there is no signal the notes survived.
Nothing writes query.loop at runtime. It is a preference read at each turn boundary and layered like any other config key, so a workspace can default it and --cfg query.loop=never can override one invocation. Per-instance control lives in the countdown menu instead: [k] Keep draft declines one specific send, the way y approves one specific tool call where Y remembers.
query.loop is a turn-end policy, not a startup policy. A draft left behind by a failed run does not fire on the next jp q; it seeds the composer per RFD 093's draft rules and the user submits it. Without this rule, if_pending would silently send stale text at invocation start.
The countdown keeps its own interrupt context:
[interrupt.loop_countdown]
# How long the countdown before sending runs.
# Set to `0` to send immediately, with no countdown and no chance to interrupt.
duration_secs = 10
# What Ctrl-C does during the countdown.
#
# - `prompt`: show the menu (default).
# - `send`: send the queued reply immediately.
# - `keep`: keep the draft and end the invocation.
# - `discard`: delete the draft and end the invocation.
# - `edit`: open the composer, then restart the countdown.
action = "prompt"
# Where `[e] Edit draft` composes: inline widget or external editor.
# Accepts `true`/`false` or `"always"`/`"never"`, as in the other contexts.
compose_in_editor = falseThe compose rule is uniform across all three contexts: each menu's compose actions use that menu's compose_in_editor. [q] reads the setting of the menu it was pressed in, and [e] reads the countdown menu's own. Nothing has to remember where a queued reply came from, and a second [q] from a different menu than the first is not a special case.
Reachability, documented because dead keys are worse than absent ones: query.loop = "never" makes all three keys unreachable, and duration_secs = 0 makes action and compose_in_editor unreachable — with no countdown there is no Ctrl-C window and therefore no menu.
The section is named for the window rather than for the queued reply because the window is what has a duration and an interrupt. The two are co-extensive today: no queued reply, no countdown. If a second interruptible turn-end subject appears — automatic compaction has the same shape, something is about to happen and the user may want to stop it — it slots in under this section, which already owns the timer, and the flat menu becomes [q] Queued reply → with the shared outcomes staying at the top level. Naming a sibling section instead would give each subject its own countdown, with no defined behavior when two are pending at one turn boundary.
duration_secs is a timing key in a section otherwise about Ctrl-C behavior, which InterruptConfig::escalation_cooldown_secs already establishes as acceptable.
RFD 093 also introduces [query]. Whichever lands first creates the section; compose_in_editor and loop are independent keys within it.
Drawbacks
- Crash recovery is conditional on user-local storage. Without it there is no safe place for a scratch file, so a queued reply lost to a crash is not recoverable. The feature itself still works.
- Renaming
PersistLevel::Turnchanges ajp_toolpublic type and two user-visible prompt labels. Small, but it is a contract. - Two renames land alongside the feature.
PersistLevel::Turn→Runandrun_turn_loop→run_turn_phases, the second taking a module file with it. Both are corrections the new turn boundary forces, but they are churn in a diff that is already structural. - Run-scoped approvals widen the unattended window. A decision keyed by tool name now covers a later turn's calls with different arguments. Bounded by the invocation, not durable, and narrowing it is the job of access grants (RFD 076) and argument-conditional policy, not this axis.
- A third interrupt menu to learn, seconds after the first two.
- The turn loop reuses the resolved
AppConfig. A tool that mutated conversation config mid-turn does not affect the queued turn. Consistent with how[r] Replybehaves within a turn today, but it is a known gap.
Alternatives
- Transition
Complete → Idleinside the phase machine. Turn-scoped state (coordinator, retry budget, pending tools, replay trim) would become run-scoped and need manual resetting, collapsing the boundary the state machine is built around. - Thread the queued text through return types (
InterruptAction→ToolInterruptResult→ExecutionResult→ turn loop). Puts a payload onExecutionOutcomethat has nothing to do with tool execution, entangling two independent axes. A single-slot sink owned by the driver keepsInterruptHandlerpure and touches neither result type. - Persist the queued reply, exit, and let the next
jp qpick it up. No turn loop, but it is not the feature: a stale message silently prepended to a later query is a surprise. This is whatquery.loop = "never"opts into deliberately. - A
persist_permissionsconfig key. Redundant with the per-press choice the prompt already offers:yis "just this once",Yis "remember". - A top-level
[queued_reply]section. A new top-level section is a cost the project is deliberately avoiding, and both[query]and[interrupt]already give each key an unambiguous home. interrupt.loop_countdown.auto_sendas a boolean. Keeps every key for the feature in one section, but the turn-end policy is not Ctrl-C behavior, and a boolean cannot express thealwayscase that turns the loop into a REPL.query.messageas the medium. Storing the queued text as a config value costs twoconfig_deltaevents per queued reply — one setting it, one clearing it — and leaves the message stored twice after consumption: in the delta and in theChatRequestthat was actually sent. Config deltas are never removed, so the text stays in the conversation's config history for its lifetime, and clearing the field cleanly needs RFD 070 rather than an empty-string sentinel.- A
QueuedRequestevent kind. Needs a new non-provider-visibleEventKind, because a plainChatRequestis on theis_provider_visibleallowlist and would be sent to the provider mid-turn as if it were an immediate reply. Consuming it means popping it and re-appending it as aChatRequest: three mutations of an append-only log, two of which are not appends. A queued reply is not a conversation event yet, and staging one in the event log makes that log temporary storage. - The pending request in conversation metadata. Mutable with no delta trail, but the metadata record is the small document read for every
jp conversation ls, and a prose body inflates every listing read. query.loopin conversation metadata. Metadata's only advantage over config is runtime mutation without a delta trail, and nothing mutatesquery.loopat runtime. Making it runtime state would let mechanism overwrite intent — a queued reply consumed underalwayswould reset the user's own preference — and would need a second field for "is something pending", which the draft's existence already answers. Config additionally gives layering,--cfg, and the generated-config.tomland JSON-schema documentation channel that a metadata field has none of.- Naming it "follow-up" or "deferred reply".
Action::SendFollowUpalready means the intra-turn provider cycle, anddeferis the detached prompt policy (RFD 049). "Queued Reply" composes with the existingReply: same noun, different delivery timing.
Non-Goals
- Type-ahead. Typing while the assistant streams, with no interrupt at all, is the natural end state. This RFD builds the slot and the turn loop it would need, but adds only the interrupt-menu writer.
- Composing without pausing output. Rendering assistant output while taking line input needs a printer-owned status line (RFD 091). Silence during compose is the intended behavior here, not a limitation to fix.
- Assistant-initiated messages. RFD 083 and RFD 094 cover the assistant addressing the user mid-turn; unrelated mechanism.
- System notification delivery. RFD 011's queue injects system messages into an existing turn's thread. A queued reply is a user-authored
ChatRequestthat starts a new turn. query.loop = "always". The third value on the axis — always run another turn, prompting when nothing is pending — is REPL mode. It is a new value on the axis this RFD introduces, not a new axis, and it brings the only prompting path with it.- A
Cancel queued replyentry in the interrupt menus. Shown only while a reply is pending, it would be a one-keystroke withdraw. Deferred until emptying the buffer proves to be a common gesture. - Bounding the number of chained queued turns. Each one requires an explicit interrupt and compose, so there is no runaway.
Risks and Open Questions
- The draft-file lifecycle is the sharp edge. Moving
QUERY_MESSAGE.mdownership to the turn loop, and preserving a config preamble on write, are both places where a mistake silently deletes text the user typed. Both need tests that assert the file survives each abnormal path. - Re-showing the menu after an emptied submit. Proposed: return to the interrupt menu, matching how
Empty | Cancelled if menubehaves today.Ctrl+Creturns to the same place without touching the slot. - Countdown legibility. A
\r-based countdown line immediately after the assistant's final output, and immediately before a new turn's header, needs a look on a real terminal. - Interaction with RFD 093. Active work there reshapes query composition and the draft's role. The queued reply writes the same file 093 reads, so the two need to agree on preamble handling; this RFD assumes 093's rule that the buffer wins for query text and the preamble is preserved untouched.
Implementation Plan
Phase 1: the turn loop
Rename run_turn_loop to run_turn_phases and turn_loop.rs to turn_phases.rs, freeing "turn loop" for the outer driver. Droppable without affecting the rest of the phase.
Add TurnEnd to run_turn_phases's return. Add the turn loop in handle_turn, driving exactly one turn. Move QUERY_MESSAGE.md cleanup from per-turn to post-loop. No user-visible change; mergeable alone.
Phase 2: the Run scope
Rename PersistLevel::Turn to Run, move TurnState::persisted_inquiry_responses to the turn-loop driver, update the two prompt labels and their tests. Behavior-preserving while a run holds one turn. Depends on phase 1.
Phase 3: queueing
The slot, the draft-file rules, [q] in both menus, seeded compose, the queue_reply value on both action enums, and query.loop with never and if_pending. Sends immediately at turn end (no countdown yet). Depends on phases 1–2.
Phase 4: the countdown
interrupt.loop_countdown with all three keys, the countdown timer, and the third interrupt menu ([n], [e], [k], [d]) with its escalation rule. [e] cancels the timer on entry and returns to a menu with no countdown running; the configured edit action restarts it instead, having no menu to return to. Depends on phase 3.
Phase 5: documentation
docs/configuration.md, the interrupt documentation, and a Queued Reply entry in the ubiquitous-language glossary. A Run entry too: one jp invocation, containing one or more Turns — a term this RFD forces into existence. The entry names which loop is which: the turn loop runs Turns within a Run, run_turn_phases runs phases within a Turn.
References
- RFD 045 — the layered interrupt handler stack this builds on, including the escalation rule the countdown menu follows.
- RFD 088 — the inline editor widget and the
compose_in_editorspectrum the compose surface reuses. - RFD 093 — query composition and the
QUERY_MESSAGE.mddraft lifecycle this RFD writes into. - RFD 076 — tool access grants, the axis for narrowing what run-scoped approvals cover.