RFD 092: Predictable and Responsive Interrupt Escalation
- Status: Discussion
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-07-03
- Extends: RFD 045, RFD 088
Summary
This RFD hardens the interrupt model from RFD 045 around two named properties — Responsive (a Ctrl-C acts immediately, anywhere in the program's lifecycle) and Predictable (in a terminal, the first press always opens a menu, the second always begins a graceful shutdown, the third always terminates) — and specifies the missing pieces: a messaged escalation ladder with a shutdown watchdog, an interrupt menu for every scope (a turn menu for the gaps between turn phases, a process-wide fallback), Ctrl-C handling inside active prompts, and an explicit non-TTY contract.
Motivation
RFD 045 built the routing machinery: a single signal consumer, a LIFO handler stack, escalation, and a root shutdown token. What it deliberately did not specify is the experience at each rung:
- Levels 2 and 3 are silent. A graceful shutdown gives no feedback, and a hung teardown has no bound — the only escape is another human press.
- Some first presses show a menu (streaming, tools) and some don't (gaps between turn phases, non-query commands). A user who has learned "1x = menu, with a chance to back out" is surprised when a press in an uncovered moment ends the run: their second press — intended for the menu — becomes a hard exit.
- A Ctrl-C while an interactive prompt is active (tool permission, tool question) is either consumed by the prompt as a cancel or deferred until the prompt completes; neither offers the interrupt menu.
The two properties make these gaps failures by definition:
- Responsive: Ctrl-C during ANY point in the program's lifecycle acts immediately, without noticeable delay.
- Predictable (TTY): 1x Ctrl-C always opens a menu, 2x always begins a graceful shutdown, 3x always terminates immediately.
Design
Where the properties hold
Startup restructures so the interrupt machinery exists before any real work: the async runtime is built first (it depends only on CLI arguments), the SignalRouter and the generic fallback handler come immediately after, and only then do workspace loading, config resolution, and the command itself run — as a single future raced by the top-level select against the fallback handler's notifications and the shutdown token. The router starts with the default escalation cooldown; interrupt.escalation_cooldown_secs is applied once config resolves. (Today the router is created in Ctx::new — after workspace sanitization, session resolution, and config resolution — so a SIGINT during startup gets the OS default: an immediate, teardown-free exit.)
Startup is synchronous code with no await points, so a press during it opens the fallback menu at the first poll — at latest when the command future starts, typically tens of milliseconds in. The press is never lost and never silent: the graceful-shutdown paths (SIGTERM, a second press) act from the first lines of the run, watchdog-bounded. In one line per lifecycle region: before the router exists, OS default (microseconds); from router creation to the first poll, queued fallback menu or watchdog-bounded graceful shutdown; from command execution on, the scope table under Handler policy.
Two qualifications:
- Default configuration. "1x opens a menu" describes default settings. A fixed
interrupt.<scope>.action(e.g.interrupt.streaming.action = "stop") deliberately replaces rung 1 for that scope; rungs 2 and 3 are unaffected. - Poll boundaries. Handlers run in their event loop's context, so a press during synchronous work (a mid-turn persist, thread building) opens the menu at the next poll point — normally within milliseconds. A second press inside that window escalates to rung 2 as usual; that is the ladder working, not a missed menu.
Interactive vs. machine output
Following RFD 048's channel model, two independent facts govern interrupt behavior, and neither is "stdout is a TTY":
- Interactive: a controlling terminal (
/dev/tty,CONOUT$) is available for prompt input and output. Menus and prompts gate on this, and only this. - Pretty output: a channel's format resolved to
text-pretty. This governs how chrome (messages, countdown lines) renders — never whether a menu exists.
Consequences: jp query --format=json in a terminal still opens menus (on /dev/tty) while stdout stays machine-readable; jp query | jq with a controlling terminal still opens menus; without a controlling terminal, rung 1 collapses (see the non-TTY contract) and shutdown messages go to stderr in whatever format stderr is configured for.
Implementation note: several existing gates use stdout().is_terminal() (ctx.term.is_tty) where they mean prompt availability — the lock-contention prompt and parts of the turn loop. Those migrate to the Interactive predicate as part of this RFD.
The escalation ladder, messaged and bounded
| Press | Behavior |
|---|---|
| 1st | Open the interrupt menu for the current scope (streaming, tool, |
| turn, or the generic fallback). | |
| 2nd | Print gracefully shutting down…, cancel the shutdown token, |
| save state, run full teardown, exit 130. | |
| 3rd | Print terminating program…, exit immediately (130). |
The second rung is reached by a second SIGINT within the escalation cooldown (interrupt.escalation_cooldown_secs) or by cancelling any interrupt menu with Ctrl-C — both paths exist today. kill -QUIT remains an immediate exit.
Menu cancel contract. In every interrupt menu, ESC means "continue": close the menu and resume the interrupted work, exactly like choosing [c]. Ctrl-C is the only key that climbs the ladder. A deliberate continue — ESC or [c] — also resets the escalation counter, so a later press is a fresh first press rather than an accidental rung 2. This supersedes RFD 045's "cancelling an interrupt menu by any means should escalate" for ESC; Ctrl-C on a menu still escalates.
Exit convention. On Unix, interrupt-initiated exits terminate by re-raising SIGINT with the default disposition — after cleanup at rung 2, immediately at rung 3 and on watchdog expiry — so parent shells observe a death-by-signal (WIFSIGNALED) and abort loops and pipelines correctly. Shells report this as exit status 130, which is the shorthand the ladder table uses. On Windows, JP exits with code 130 directly.
Shutdown watchdog. When the shutdown token cancels, a watchdog task starts. After a configurable delay it renders a countdown line; if the countdown reaches zero with the process still alive, it prints terminating program… and exits — a graceful shutdown can no longer hang. The watchdog's countdown absorbs the drain's current 2-second "Cancelling background tasks…" line: one countdown covers the whole teardown.
[interrupt.shutdown]
show = true # render the countdown line
delay_secs = 1 # silence before the countdown appears
interval_ms = 100 # countdown update rate
timeout_secs = 10 # hard-kill deadline for the whole graceful shutdownThe shape mirrors style.lock_wait and the other timer configs. SIGTERM enters the same path: message, countdown, watchdog.
| Key | Default | Zero means | Affects |
|---|---|---|---|
show | true | — | Display only |
delay_secs | 1 | Countdown appears immediately | Display |
interval_ms | 100 | Clamped to a minimum update rate | Display |
timeout_secs | 10 | Zero graceful deadline: hard-kill now | Enforcement |
show = false never disables the watchdog: the shutdown stays bounded, just silent.
Ownership. Every cancellation path — the router's second press, SIGTERM, an unhandled first press, and the menu escalations inside the turn loop — converges on the shutdown token, so a single watcher covers them all: run_inner spawns the watchdog once, as soon as the router exists. The watchdog owns the countdown rendering (via the Printer) and the hard-exit deadline; the router stays free of terminal UI, per RFD 045.
Output discipline. Every new message and countdown goes through the Printer on the chrome channel (stderr), with a concrete shape per format:
text-pretty: the countdown is a single\r-rewritten line, like the existing timers; the two messages are plain lines.text: the two messages print as plain lines; no in-place rewrites and no countdown updates.json: the two messages are stderr NDJSON records ({"message":"gracefully shutting down…"}); countdown updates are suppressed entirely — a display-only suppression, the watchdog still enforces.
Implementation note: the Printer's stderr methods currently bypass NDJSON wrapping (only stdout prints wrap), which already falls short of RFD 048's "chrome on stderr is NDJSON under --format json" rule; phase 1 extends the wrapping to stderr chrome.
A menu at every rung 1 (Interactive)
Turn menu. The turn-level handler ends the turn silently today. It gains a menu — the turn menu — hosted in the turn loop's own context like the streaming menu, shown when a press lands in the gaps between turn phases (persistence, thread building, response processing):
[c] Continue turn
[r] Reply (inject a message, continue)
[s] Stop (save & exit)Choosing [s] is a deliberate completion (exit 0); cancelling the turn menu with Ctrl-C is a second press (escalate, exit 130) — the same rule as every other menu. Reply reuses the streaming menu's machinery (commit partials, inject the ChatRequest, prepare a continuation); pending tool calls are covered by the existing sanitize pass. No Abort option: at a gap the previous cycle is already persisted, so there is nothing unsaved to discard.
The turn scope gets the same configuration shape as the other menu scopes:
[interrupt.turn]
action = "prompt" # prompt | continue | reply | stop
compose_in_editor = false # same semantics as interrupt.streamingIn particular, compose_in_editor gives the turn menu's Reply the same straight-to-editor opt-in as the streaming menu.
Generic fallback menu. run_inner registers a bottom-of-stack handler before workspace and config loading; the top-level select polls it alongside the startup-and-command future (see "Where the properties hold"). A first press anywhere without a more specific handler opens a minimal menu:
[c] Continue
[q] Quit gracefullyBecause the menu blocks the select task, the command's main-future work pauses while the menu is up (the same pattern as the streaming menu) and resumes on [c]. Spawned background work continues meanwhile; if the command finishes the moment the user picks continue, it simply completes. This delivers "1x always = menu" program-wide with one handler instead of a per-command migration. The fallback menu has no Reply option — it can fire outside any turn or even any conversation — so there is no straight-to-editor path, and it has no configuration surface: it is the invariant floor of the ladder, and an action override here would reintroduce "1x silently does something".
Menu erase-on-close. Menus wipe themselves after a choice: inquire prompts already clear their body and rewrite a one-line answered prompt, which the caller then erases (cursor-up + clear-line on the writer it owns); the reedline-based InlineReply does the same. Caveat: if rendering the menu scrolled the terminal, earlier content shifts into scrollback — the screen ends up clean but shifted. Erasing the InlineReply widget after submit requires restoring the explicit echo of the submitted reply through the normal rendering path (the echo was previously removed precisely because the surviving widget already showed it).
Handler policy
A handler may consume a first press only by presenting a user-facing choice; anything else declines (SignalRouter::decline) so the next handler's menu shows. Scopes that delegate the interrupt experience elsewhere are named here, not implied.
| Scope | Rung 1 behavior |
|---|---|
| Streaming loop | Streaming menu |
| Tool execution | Tool menu |
| Turn gaps | Turn menu |
| Lock wait | Lock-contention prompt (the scope's menu) |
| Anything else | Generic fallback menu |
| Plugin dispatch | Delegated to the plugin (see below) |
Plugin scope. A plugin run delegates rung 1 to the child process, which may render its own terminal UI:
- 1st press: the host forwards SIGINT to the plugin's process group. The child is spawned in its own process group precisely so the terminal cannot broadcast Ctrl-C into it — delivery is the host's deliberate choice. A plugin that handles SIGINT shows its own interrupt UX; one that doesn't dies, as any CLI would.
- 2nd press: JP takes over — the shutdown token cancels, the dispatch thread sends
HostToPlugin::Shutdown, and the existing grace-then-SIGKILL window runs. - 3rd press: no protocol message — the child is SIGKILLed via a process-global kill-on-exit registry that the router's exit path walks, and JP exits.
The plugin's piped std streams remain protocol and tracing channels throughout; a plugin that offers its own interrupt UX renders it on its controlling terminal (/dev/tty), mirroring JP's own prompt model from RFD 048. This refines RFD 072's "the plugin never writes directly to the user's terminal" for the interactive case — an alignment to settle in 072 when it advances; this RFD takes no dependency on it.
Signal forwarding is Unix-only; Windows keeps the current behavior (see Non-Goals).
Ctrl-C inside active prompts
inquire distinguishes the keys: ESC yields OperationCanceled, Ctrl-C yields OperationInterrupted (crossterm backend). JP's call sites currently collapse both. What Ctrl-C means depends on what is on screen:
| Context | Ctrl-C |
|---|---|
| An interrupt menu | Escalate (rung 2) |
| A reply opened from an interrupt menu | Back to that menu (RFD 088, |
| unchanged) | |
| Any other prompt (permission, question, | Swap to the scope's interrupt |
| result edit) | menu, [b] back appended |
- ESC backs out of any prompt (its existing cancel meaning); in interrupt menus it means continue (see the menu cancel contract).
- Ctrl-C in a standalone prompt swaps to the current scope's interrupt menu, with a
[b] back to promptentry appended;backre-shows the prompt from the queue. Cancelling that menu escalates as usual.
Scope resolution. "The current scope" is the topmost registered handler at the moment of the press; no prompt installs a scope of its own:
| Prompt | Typical phase | Menu shown |
|---|---|---|
| Tool permission (streaming path) | Streaming | Streaming menu |
| Tool permission (restart prep) | Turn gap | Turn menu |
| Tool question / result edit | Tool execution | Tool menu |
| Reply opened from an interrupt menu | Any | Back to that menu |
There is no dedicated permission menu: the tool menu's options assume a running tool ("continue — wait for tool", "restart"), which a pre-approval prompt does not have; the ambient scope's menu plus [b] back covers the need.
Prompt ownership. The swap-and-return flow requires every prompt to be re-showable, which means every prompt must exist as queued data. Today only tool questions and result edits live in the pending-prompt queue (PendingPrompt); permission prompts are resolved synchronously — the streaming path calls resolve_tool_call_decision inline and awaits the answer. This RFD unifies them: permission prompts join the queued model (one representation for permission, question, and result-edit prompts). The streaming path keeps its ordering constraint — it still awaits resolution before processing further LLM events; the queue entry exists so [b] back has somewhere to return to.
Widget outcomes. The reedline-based InlineReply splits Signal::CtrlC from its other cancel paths: ReplyOutcome::Interrupted (Ctrl-C) becomes distinct from ReplyOutcome::Cancelled (ESC and other local cancels); the caller decides what each means, as before. Queued presses are handled at both ends: the coordinator drains a pending interrupt notification before displaying any prompt, and the in-tree widgets poll a cancellation source between terminal events so an already-displayed prompt reacts to a press that arrived as a signal.
Relationship to RFD 088. RFD 088 states that Ctrl-C inside InlineReply is a local reply cancellation, never RFD 045 escalation. This RFD narrows that rule to the context where it holds — replies opened from an interrupt menu — and gives all other prompts the swap behavior. The widget keeps 088's core principle (outcomes are data; the caller decides what they mean); 088's note is corrected in place when this RFD is implemented.
Non-TTY contract
Without a controlling terminal there is no Ctrl-C — only signals delivered via kill(2) — and no menus (inquire refuses with NotTTY). Rung 1 collapses; every press still climbs exactly one rung:
- SIGINT: begin a graceful shutdown (message on stderr, watchdog-bounded); a further SIGINT exits immediately.
- SIGTERM: graceful shutdown, watchdog-bounded — comfortably inside a supervisor's TERM → wait → KILL contract (systemd default: 90s).
- SIGQUIT: immediate exit.
Exits follow the exit convention above: death by re-raised SIGINT on Unix (observed as status 130), exit(130) on Windows.
Drawbacks
- The generic fallback menu pauses the command's main-future work while displayed; spawned work races on. A menu over a finished command is possible and harmless, but slightly odd.
- The prompt-swap flow (
Ctrl-C→ interrupt menu →back) adds a state transition to the prompt queue and a re-display path that must preserve prompt content exactly. - Unifying permission prompts into the pending-prompt queue is a real refactor of the tool coordinator's prompt flow, not just new UI.
- More configuration surface (
interrupt.shutdown) and more chrome output to keep correct across--formatmodes.
Alternatives
- Swallow the first press during prompts (do nothing until the prompt closes): rejected — it violates both properties; a press that does nothing visible trains users to press again, which then skips a rung.
- Alternate screen buffer for menus (perfect restoration): rejected — it hides the streamed context the user needs while choosing.
- Per-command interrupt scopes instead of the generic fallback menu: rejected as the default path — it requires migrating every long-running command; the bottom-of-stack handler covers all of them at once. Commands can still register richer scopes where useful.
- Repurpose SIGQUIT as "graceful with core-dump semantics": rejected; RFD 045 fixed SIGQUIT as the unconditional exit and supervisors do not send it expecting cleanup.
- A
HostToPlugin::Interruptprotocol message instead of forwarding SIGINT: rejected — plugins are ordinary CLIs that already understand SIGINT; a protocol message needs a version bump and only reaches plugins that poll stdin mid-operation.
Non-Goals
- Interrupt semantics for
inquire-crate prompts beyond what its error variants already expose; deeper integration (e.g. widget-level cancellation insideinquireprompts) waits until the fork is inlined intocrates/contrib. - Signal handling on Windows beyond the existing Ctrl-C/Ctrl-Break mapping.
- Cross-process interrupt routing (RFD 027 territory).
Risks and Open Questions
- Watchdog vs. slow-but-healthy teardown. A teardown legitimately slower than
timeout_secs(large workspace persist on a slow disk) gets killed. The default must be generous enough for real workloads; 10 seconds is a starting point, not a measurement. - Menu over mid-line output. The fallback menu can appear while chrome or streamed content is mid-line; the renderer must start the menu on a clean line and erase back to the interruption point.
- Prompt re-display fidelity.
backmust re-render prompts whose preamble was built from transient state (e.g. diffs in permission prompts); the pending-prompt queue already carries the full prompt data, but this needs verification per prompt kind. - Replay safety of saved interrupts. Graceful shutdown and the menus' save options persist the same partial-event state JP preserves today. Some providers may reject replay of interrupted reasoning blocks (issue #829, Anthropic
reasoning_extractionrefusals) until the partial-reasoning recovery work lands. This RFD adds save paths, not new saved shapes.
Implementation Plan
Phase 1: Early router, messaged ladder, and shutdown watchdog
Restructure startup: build the runtime first (it depends only on CLI arguments), create the SignalRouter (default cooldown, updated once config resolves), and run workspace/config/command as one future under the top-level select. Introduce the Interactive predicate (controlling-terminal availability, exposed via Ctx); every new menu in later phases gates on it. [interrupt.shutdown] config, the gracefully shutting down… / terminating program… messages, the exit convention, the watchdog task owned by run_inner, absorbing the drain's countdown, and the ESC-continue / counter-reset menu cancel contract. All output through the Printer per the output discipline, including NDJSON wrapping for stderr chrome. Independent of the other phases.
Phase 2: Turn menu
The turn-level handler shows the turn menu (continue / reply / stop) instead of silently completing the turn, with the [interrupt.turn] config block. Independent; small.
Phase 3: Generic fallback menu
Bottom-of-stack handler in run_inner with the continue / quit menu, plus menu erase-on-close for the interrupt menus (including the InlineReply echo restoration). Depends on phase 1 for consistent messaging.
Phase 4: Prompt queue unification
Move permission prompts into the pending-prompt model shared with tool questions and result edits, preserving the streaming path's await-before-continue ordering. Independent of phases 1–3; prerequisite for phase 5.
Phase 5: Ctrl-C inside prompts
Split ESC from Ctrl-C at all prompt call sites (OperationCanceled vs OperationInterrupted), the distinct ReplyOutcome::Interrupted in InlineReply, the prompt → interrupt-menu swap with back, pending-notification drain before prompts, and widget-level cancellation polling in the in-tree widgets. The largest phase; depends on phases 1–2 for the menus it swaps to and on phase 4 for the queue it returns through.
Phase 6: Plugin scope ladder
Forward SIGINT to the plugin's process group on the first press; add the kill-on-exit registry the router's exit path walks on the third. Independent.
Phase 7: Interactive gating and the non-TTY contract
Migrate the existing is_tty prompt gates (lock wait, turn loop) to the Interactive predicate; document and test the non-interactive ladder; implement the SIGINT re-raise exit convention. Independent.
References
- RFD 045 — the signal router, handler stack, and escalation this RFD builds on.
- RFD 088 — the
InlineReplywidget whose Ctrl-C contract this RFD refines. - RFD 048 — the four-channel output model the Interactive / Pretty distinction follows.
- RFD 027 — client-server query architecture; interrupt routing over IPC remains out of scope here.