RFD D60: Plugin Prompt Channel
- Status: Draft
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-07-25
- Extends: RFD 072
Summary
This RFD adds a prompt request to the command plugin protocol (RFD 072). A plugin describes a question — confirm, select, text line, or secret — and the host renders it through its own prompt facilities, then replies with the answer. This mirrors how tool inquiries already work: the component that needs an answer describes the question; the component that owns the terminal asks it.
Motivation
A command plugin's stdin and stdout carry the host protocol, so a plugin cannot read from the terminal. The protocol lets a plugin emit output (print, log) but not request input. There is no way to ask the user a question: a confirmation before a destructive action, a choice between alternatives, a pasted value.
A plugin that prunes stale data has no way to ask "delete 14 entries?" Today it must either proceed silently, refuse to run, or reimplement a prompt outside the protocol — which means driving the terminal directly, breaking the RFD 072 contract that only the host touches the user's terminal, and losing the host's styling, keybindings, and interrupt handling.
The host already has everything needed to ask: PromptBackend (jp_inquire::prompt) with inline_select, text, and select, the same primitives every host-owned prompt uses. The missing piece is a way for the plugin to reach them.
Design
Wire format
A plugin sends a prompt request and blocks until the host replies with prompt_response. This slots into RFD 072's existing request/response grain — like read_config → config — including the optional echoed id for plugins that pipeline concurrent requests.
The request carries a tagged kind:
{
"type": "prompt",
"kind": "confirm",
"message": "Delete 14 stale entries?",
"default": false
}{
"type": "prompt",
"kind": "select",
"message": "Which branch?",
"options": ["main", "develop", "release"]
}{
"type": "prompt",
"kind": "text",
"message": "Commit message"
}{
"type": "prompt",
"kind": "secret",
"message": "Registry token"
}The host replies with an outcome:
{"type": "prompt_response", "outcome": "answered", "value": true}
{"type": "prompt_response", "outcome": "cancelled"}
{"type": "prompt_response", "outcome": "unavailable", "reason": "no interactive terminal"}value is typed by kind: a bool for confirm, the chosen string for select, the entered string for text and secret. cancelled means the user dismissed the prompt (ESC). unavailable means the host had no interactive terminal to render into; the plugin converts it into its own diagnostic naming its non-interactive alternative, so plugins stay scriptable by default rather than blocking.
One request, a tagged kind
The kind enum mirrors the host's own model of a question. jp_tool::AnswerType (Boolean / Select / Text, plus Secret from RFD 082) is how ToolPrompter::prompt_question already dispatches inquiries. Reusing that shape at the protocol boundary keeps one ubiquitous concept — "a question with an answer type" — rather than inventing a second vocabulary, and makes a future kind a single variant instead of a new message type, a new handler, and a new response.
The kinds map directly onto the existing backend: confirm → inline_select (y/n), select → select, text → text, secret → the no-echo password method added by RFD 082. The terminal styling, keybindings, and interrupt behavior are inherited for free.
Rendering stays in the host shell
jp_plugin defines only the message types; it stays a pure protocol-types crate. All rendering lives in jp_cli, which owns the terminal.
The host's plugin message loop (cmd/plugin/dispatch.rs) currently has no printer and no prompt backend — it holds only the workspace, config, and shutdown flag. Rendering a prompt requires threading a narrow prompt context — Arc<Printer>, Arc<dyn PromptBackend>, and the host's interactivity signal — into the loop. This is deliberately the minimum surface: the loop does not gain access to the whole Ctx.
Threading the printer in also closes an existing gap: PluginToHost::Print is a Phase-1 placeholder that writes raw bytes to stdout with a TODO to route through the Printer. The same context that renders prompts routes print correctly, so the cost is paid once.
Outcomes are typed, not errors
cancelled and unavailable are modelled as outcomes on prompt_response, not as RFD 072's shared error message. Cancellation is a normal result, not a failure, and a single-threaded shell plugin branches on outcome with one jq check instead of distinguishing "my prompt was refused" from "my earlier read_events failed."
unavailable is gated on the host's interactivity signal, which today is Ctx::term.is_tty. This RFD does not build a new policy for it. When RFD 049 lands (route_prompt() / has_client, --non-interactive), this call site becomes one more consumer of that router; the seam is left in place, not depended on.
Secret prompts
A secret prompt uses no-echo input and its answer is never logged. Secret-ness is a kind, not a secret: bool flag on text — the same decision RFD 082 makes for inquiries, where encoding it as an AnswerType::Secret variant keeps the ambiguous "secret boolean" and "secret select" shapes unrepresentable. The secret kind consumes the no-echo password method RFD 082 adds to PromptBackend.
RFD 082's redaction machinery does not apply here. That machinery — InquiryResponse::Redacted { id }, the <redacted> rendering — concerns secret answers persisted to the conversation stream. A plugin prompt is ephemeral request/response and never becomes an InquiryEvent. The only never-logged obligation on this channel is that a secret answer must not flow through the loop's trace!(?msg) message logging.
Interrupt handling
The dispatcher already participates in the layered interrupt handler stack (RFD 045): run_plugin holds an interrupt guard, and the shutdown thread relays Shutdown to the plugin on escalation. A plugin prompt renders through TerminalPromptBackend, which puts the terminal in raw mode, so per RFD 045's dual-delivery model a Ctrl-C during the prompt arrives as byte 0x03 to the prompt library rather than as SIGINT to the router. The blocking prompt cancels itself; the host does not need the shutdown thread to unblock it.
A plugin prompt is a non-interrupt prompt, in the same category as a tool question, so it reuses the existing cancel-versus-interrupt mapping:
- ESC (
OperationCanceled) → replyoutcome: "cancelled". - Ctrl-C (
OperationInterrupted) → escalate to graceful shutdown, which sends the plugin its existingShutdown.
The one implementation note: the loop must not hold the plugin-stdin write lock across the blocking terminal read, so the router's normal-mode path and the shutdown thread stay unobstructed in the brief window before raw mode engages. The lock is acquired only to write prompt_response.
Drawbacks
Single-threaded plugins block while the prompt is up. A shell script that sends
promptand reads the response blocks until the user answers. This is the intended behavior — the plugin asked a question — but it means a plugin cannot do other work while a prompt is pending. Multi-threaded plugins that need concurrency use theidfield.Social-engineering surface. A plugin can render arbitrary prompt text, including text that impersonates the host ("JP: enter your password"). This is not gated further here:
RunPolicyalready governs whether a plugin runs at all, and a plugin the user has chosen to run can already print arbitrary text.Another synchronous round-trip. Each prompt is a request, a blocking render, and a response. For interactive use this is free (the human is the bottleneck); it is not a batch-throughput path.
Alternatives
Secret as a secret: bool flag on text
Rejected for the same reason RFD 082 rejects it for inquiries: a boolean on a separate axis makes "secret boolean" and "secret select" representable and meaningless. A kind variant keeps the type space honest and matches the host's AnswerType.
Overload the shared error message for cancel and unavailable
Rejected because cancellation is a normal outcome, not a failure, and folding both into error forces every plugin to disambiguate a refused prompt from an unrelated failed request. A typed outcome keeps prompt semantics self-contained.
One message type per shape (confirm / select / prompt_text)
Rejected because it triples the handler and response surface and makes each new shape a new message type rather than a new enum variant. One prompt with a tagged kind composes better.
Reuse RFD D18's interactive events
RFD D18 adds interactive events, but in the opposite direction: the host pushes its own inquiry to the plugin to render in a browser (respond: "inquiry"). This RFD is the reverse — the plugin has a question and wants the host's terminal to ask it. Different direction, different audience (a shell script can use prompt with read; RFD D18 needs a multi-threaded plugin with subscriptions), and independently shippable. The two are complementary axes on RFD 072, not the same feature.
Non-Goals
- Host-to-plugin interactive events. Pushing the host's own tool-approval and inquiry prompts to a plugin frontend is RFD D18's concern, not this one.
- Recording prompts in the conversation stream. A plugin prompt is ephemeral. It is not an
InquiryEventand does not persist. - Multi-question forms. One
promptasks one question. Batching, branching, and cancel-UX for multi-field forms are out of scope. - A non-interactive answer policy. Beyond replying
unavailable, deciding how a non-interactive host should answer (auto-confirm, default, fail) is RFD 049's concern.
Risks and Open Questions
Interrupt window. The correctness of the interrupt behavior rests on the loop releasing the plugin-stdin write lock across the blocking read and on the raw-mode dual-delivery path from RFD 045. This needs a test that a Ctrl-C during a plugin prompt escalates rather than wedging the terminal.
Stdout hygiene. The prompt render writes to the terminal, not to the plugin's stdout, so it does not corrupt the JSON-lines stream. This holds only as long as rendering goes through the
Printer/ prompt backend and never through the plugin's own stdout handle.Secret leakage. The never-logged guarantee depends on the
secretanswer bypassing the loop'strace!(?msg). This is a discipline the implementation must enforce and a test must pin.
Implementation Plan
Phase 1: Core prompt (confirm / select / text)
- Add
PluginToHost::PromptandHostToPlugin::PromptResponse(with theoutcomeenum) tojp_plugin::message. - Thread a narrow prompt context (
Arc<Printer>,Arc<dyn PromptBackend>, interactivity signal) into the dispatch message loop. - Render
confirm/select/textthrough the existingPromptBackend; gateunavailableonCtx::term.is_tty. - Route
unavailable,cancelled, andansweredoutcomes; reuse the tool-question ESC/Ctrl-C mapping from RFD 045. - Route
PluginToHost::Printthrough the samePrintercontext, closing the existing placeholder. - Depends on RFD 072 Phase 1 (protocol core). Reviewable and mergeable on its own.
Phase 2: Secret prompt
- Add the
secretkind and render it through RFD 082's no-echopasswordmethod onPromptBackend. - Ensure the secret answer bypasses the loop's message tracing.
- Depends on RFD 082 landing the
passwordbackend method. Sequenced after Phase 1; does not block it.
References
- RFD 072: Command Plugin System — the protocol this extends.
- RFD 082: Unified inquiry event recording — the
AnswerType::Secretmodel and thepasswordbackend method. - RFD 045: Layered Interrupt Handler Stack — the interrupt stack the dispatcher already uses; the raw-mode dual-delivery and cancel/escalate mapping.
- RFD 049: Non-Interactive Mode and Detached Prompt Policy — the future home of the interactivity policy
unavailableis gated on. - RFD D18: Plugin Event Subscriptions and Query Delegation — the host-to-plugin interactive-events axis, complementary to this one.