From 77f51872385def0cc3b0fd48580ac402c56d1a15 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Fri, 31 Jul 2026 17:04:57 +0200 Subject: [PATCH 1/2] docs(rfd): Add D46 draft for queued replies Signed-off-by: Jean Mertz --- docs/rfd/drafts/D46-queued-replies.md | 512 ++++++++++++++++++++++++++ 1 file changed, 512 insertions(+) create mode 100644 docs/rfd/drafts/D46-queued-replies.md diff --git a/docs/rfd/drafts/D46-queued-replies.md b/docs/rfd/drafts/D46-queued-replies.md new file mode 100644 index 00000000..4b308fb2 --- /dev/null +++ b/docs/rfd/drafts/D46-queued-replies.md @@ -0,0 +1,512 @@ +# RFD D46: Queued Replies + +- **Status**: Draft +- **Category**: Design +- **Authors**: Jean Mertz +- **Date**: 2026-07-31 +- **Extends**: [RFD 045](../045-layered-interrupt-handler-stack.md) + +## 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_file` runs 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. +- The file is a `QueryDocument` (config preamble plus query text), while + `preserve_query_message_file` writes 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: + +```rust +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: + +```rust +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 draft +``` + +`s` 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: + +```toml +[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: + +```toml +[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: + +```toml +[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 = false +``` + +The 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::Turn` changes a `jp_tool` public type** and two + user-visible prompt labels. + Small, but it is a contract. +- **Two renames land alongside the feature.** `PersistLevel::Turn` → `Run` and + `run_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] Reply` behaves within a turn today, but it is a + known gap. + +## Alternatives + +- **Transition `Complete → Idle` inside 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 on + `ExecutionOutcome` that has nothing to do with tool execution, entangling two + independent axes. + A single-slot sink owned by the driver keeps `InterruptHandler` pure and + touches neither result type. +- **Persist the queued reply, exit, and let the next `jp q` pick 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 what `query.loop = "never"` opts into deliberately. +- **A `persist_permissions` config key.** Redundant with the per-press choice + the prompt already offers: `y` is "just this once", `Y` is "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_send` as 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 the `always` case that turns the loop into a REPL. +- **`query.message` as the medium.** Storing the queued text as a config value + costs two `config_delta` events per queued reply — one setting it, one clearing + it — and leaves the message stored twice after consumption: in the delta and in + the `ChatRequest` that 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 `QueuedRequest` event kind.** Needs a new non-provider-visible `EventKind`, + because a plain `ChatRequest` is on the `is_provider_visible` allowlist 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 a `ChatRequest`: 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.loop` in conversation metadata.** Metadata's only advantage over config + is runtime mutation without a delta trail, and nothing mutates `query.loop` at + runtime. + Making it runtime state would let mechanism overwrite intent — a queued reply + consumed under `always` would 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.toml` and + JSON-schema documentation channel that a metadata field has none of. +- **Naming it "follow-up" or "deferred reply".** `Action::SendFollowUp` already + means the intra-turn provider cycle, and `defer` is the detached prompt policy + ([RFD 049]). + "Queued Reply" composes with the existing `Reply`: 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 `ChatRequest` that 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 reply` entry 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.md` + ownership 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 menu` behaves today. + `Ctrl+C` returns 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_editor` spectrum the + compose surface reuses. +- [RFD 093] — query composition and the `QUERY_MESSAGE.md` draft lifecycle this + RFD writes into. +- [RFD 076] — tool access grants, the axis for narrowing what run-scoped + approvals cover. + +[RFD 011]: ../011-system-notification-queue.md +[RFD 045]: ../045-layered-interrupt-handler-stack.md +[RFD 049]: ../049-non-interactive-mode-and-detached-prompt-policy.md +[RFD 070]: ../070-negative-config-deltas.md +[RFD 076]: ../076-tool-access-grants.md +[RFD 083]: ../083-built-in-ask_user-tool-for-assistant-initiated-inquiries.md +[RFD 088]: ../088-unified-editor-service-and-inline-reply-widget.md +[RFD 091]: ../091-printer-owned-status-line.md +[RFD 093]: ../093-inline-first-query-composition.md +[RFD 094]: ../094-built-in-tell_user-tool-for-mid-turn-user-addressed-messages.md From 9882e981f1490b6e9cd2bdb7a805d20fcd4990f6 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Fri, 31 Jul 2026 17:13:55 +0200 Subject: [PATCH 2/2] fixup! docs(rfd): Add D46 draft for queued replies Signed-off-by: Jean Mertz --- docs/rfd/drafts/D46-queued-replies.md | 164 +++++++++++++------------- 1 file changed, 83 insertions(+), 81 deletions(-) diff --git a/docs/rfd/drafts/D46-queued-replies.md b/docs/rfd/drafts/D46-queued-replies.md index 4b308fb2..cf70e453 100644 --- a/docs/rfd/drafts/D46-queued-replies.md +++ b/docs/rfd/drafts/D46-queued-replies.md @@ -4,7 +4,7 @@ - **Category**: Design - **Authors**: Jean Mertz - **Date**: 2026-07-31 -- **Extends**: [RFD 045](../045-layered-interrupt-handler-stack.md) +- **Extends**: [RFD 045] ## Summary @@ -51,8 +51,8 @@ Interrupted ``` `[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. +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. @@ -64,23 +64,23 @@ 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. +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. | +| 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` | Slot unchanged. Back to the menu. | -`Ctrl+C` means "back up a level, change nothing", exactly as it does on the reply -path. +`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 @@ -129,8 +129,8 @@ 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`. +(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 @@ -203,14 +203,14 @@ would invert them. `[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. +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. +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. @@ -234,8 +234,8 @@ action = "queue_reply" 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: +What happens when a turn ends is a `[query]` key, because it is a property of +the invocation rather than of Ctrl-C: ```toml [query] @@ -248,11 +248,11 @@ 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. +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 +`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. @@ -262,12 +262,12 @@ 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. +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. +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. @@ -294,22 +294,22 @@ compose_in_editor = false ``` The 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. +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. +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 +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. @@ -324,8 +324,8 @@ 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 +- **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::Turn` changes a `jp_tool` public type** and two @@ -333,8 +333,8 @@ independent keys within it. Small, but it is a contract. - **Two renames land alongside the feature.** `PersistLevel::Turn` → `Run` and `run_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. + 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 @@ -342,8 +342,8 @@ independent keys within it. - **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] Reply` behaves within a turn today, but it is a - known gap. + Consistent with how `[r] Reply` behaves within a turn today, but it is a known + gap. ## Alternatives @@ -352,49 +352,50 @@ independent keys within it. 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 on - `ExecutionOutcome` that has nothing to do with tool execution, entangling two - independent axes. + `ToolInterruptResult` → `ExecutionResult` → turn loop). + Puts a payload on `ExecutionOutcome` that has nothing to do with tool + execution, entangling two independent axes. A single-slot sink owned by the driver keeps `InterruptHandler` pure and touches neither result type. - **Persist the queued reply, exit, and let the next `jp q` pick it up.** No - turn loop, but it is not the feature: a stale message silently prepended to - a later query is a surprise. + turn loop, but it is not the feature: a stale message silently prepended to a + later query is a surprise. This is what `query.loop = "never"` opts into deliberately. - **A `persist_permissions` config key.** Redundant with the per-press choice the prompt already offers: `y` is "just this once", `Y` is "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. +- **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_send` as 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 the `always` case that turns the loop into a REPL. - **`query.message` as the medium.** Storing the queued text as a config value - costs two `config_delta` events per queued reply — one setting it, one clearing - it — and leaves the message stored twice after consumption: in the delta and in - the `ChatRequest` that 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 `QueuedRequest` event kind.** Needs a new non-provider-visible `EventKind`, - because a plain `ChatRequest` is on the `is_provider_visible` allowlist and - would be sent to the provider mid-turn as if it were an immediate reply. + costs two `config_delta` events per queued reply — one setting it, one + clearing it — and leaves the message stored twice after consumption: in the + delta and in the `ChatRequest` that 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 `QueuedRequest` event kind.** Needs a new non-provider-visible + `EventKind`, because a plain `ChatRequest` is on the `is_provider_visible` + allowlist 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 a `ChatRequest`: 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. + 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.loop` in conversation metadata.** Metadata's only advantage over config - is runtime mutation without a delta trail, and nothing mutates `query.loop` at - runtime. + but the metadata record is the small document read for every `jp conversation + ls`, and a prose body inflates every listing read. +- **`query.loop` in conversation metadata.** Metadata's only advantage over + config is runtime mutation without a delta trail, and nothing mutates + `query.loop` at runtime. Making it runtime state would let mechanism overwrite intent — a queued reply - consumed under `always` would 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.toml` and - JSON-schema documentation channel that a metadata field has none of. + consumed under `always` would 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.toml` + and JSON-schema documentation channel that a metadata field has none of. - **Naming it "follow-up" or "deferred reply".** `Action::SendFollowUp` already means the intra-turn provider cycle, and `defer` is the detached prompt policy ([RFD 049]). @@ -428,8 +429,8 @@ independent keys within it. ## Risks and Open Questions - **The draft-file lifecycle is the sharp edge.** Moving `QUERY_MESSAGE.md` - ownership to the turn loop, and preserving a config preamble on write, are both - places where a mistake silently deletes text the user typed. + ownership 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 menu` behaves today. @@ -474,10 +475,11 @@ 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. +`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